Bug 1308656 - Add shield-recipe-client as system add-on draft
authorMythmon <mcooper@mozilla.com>
Mon, 10 Oct 2016 16:14:56 -0700
changeset 439270 0f57130a738806e510c65fd467e7284d59eea050
parent 439149 f8ba9c9b401f57b0047ddd6932cb830190865b38
child 537115 8684262ed1d155e26a2bf991f76c6042949eaf46
push id35947
push userbmo:mcooper@mozilla.com
push dateTue, 15 Nov 2016 18:18:01 +0000
bugs1308656
milestone53.0a1
Bug 1308656 - Add shield-recipe-client as system add-on MozReview-Commit-ID: KNTGKOFXDlH
addon-sdk/source/python-lib/cuddlefish/prefs.py
addon-sdk/source/test/preferences/no-connections.json
browser/extensions/moz.build
browser/extensions/shield-recipe-client/bootstrap.js
browser/extensions/shield-recipe-client/chrome.manifest
browser/extensions/shield-recipe-client/data/EventEmitter.js
browser/extensions/shield-recipe-client/index.js
browser/extensions/shield-recipe-client/install.rdf
browser/extensions/shield-recipe-client/lib/CleanupManager.jsm
browser/extensions/shield-recipe-client/lib/EnvExpressions.jsm
browser/extensions/shield-recipe-client/lib/Heartbeat.jsm
browser/extensions/shield-recipe-client/lib/Log.jsm
browser/extensions/shield-recipe-client/lib/NormandyApi.jsm
browser/extensions/shield-recipe-client/lib/NormandyDriver.jsm
browser/extensions/shield-recipe-client/lib/RecipeRunner.jsm
browser/extensions/shield-recipe-client/lib/Sampling.jsm
browser/extensions/shield-recipe-client/lib/SandboxManager.jsm
browser/extensions/shield-recipe-client/lib/SelfRepairInteraction.jsm
browser/extensions/shield-recipe-client/lib/Storage.jsm
browser/extensions/shield-recipe-client/moz.build
browser/extensions/shield-recipe-client/node_modules/jexl/LICENSE.txt
browser/extensions/shield-recipe-client/node_modules/jexl/lib/Jexl.js
browser/extensions/shield-recipe-client/node_modules/jexl/lib/Lexer.js
browser/extensions/shield-recipe-client/node_modules/jexl/lib/evaluator/Evaluator.js
browser/extensions/shield-recipe-client/node_modules/jexl/lib/evaluator/handlers.js
browser/extensions/shield-recipe-client/node_modules/jexl/lib/grammar.js
browser/extensions/shield-recipe-client/node_modules/jexl/lib/parser/Parser.js
browser/extensions/shield-recipe-client/node_modules/jexl/lib/parser/handlers.js
browser/extensions/shield-recipe-client/node_modules/jexl/lib/parser/states.js
browser/extensions/shield-recipe-client/node_modules/jexl/package.json
browser/extensions/shield-recipe-client/package.json
browser/extensions/shield-recipe-client/test/.eslintrc.js
browser/extensions/shield-recipe-client/test/TestUtils.jsm
browser/extensions/shield-recipe-client/test/jetpack-package.ini
browser/extensions/shield-recipe-client/test/test-EventEmitter.js
browser/extensions/shield-recipe-client/test/test-Heartbeat.js
browser/extensions/shield-recipe-client/test/test-driver.js
browser/extensions/shield-recipe-client/test/test-env-expressions.js
browser/extensions/shield-recipe-client/test/test-storage.js
layout/tools/reftest/reftest-preferences.js
testing/profiles/prefs_general.js
testing/talos/talos/config.py
testing/xpcshell/head.js
--- a/addon-sdk/source/python-lib/cuddlefish/prefs.py
+++ b/addon-sdk/source/python-lib/cuddlefish/prefs.py
@@ -73,16 +73,17 @@ DEFAULT_NO_CONNECTIONS_PREFS = {
 
     # Point update checks to a nonexistent local URL for fast failures.
     'extensions.update.url' : 'http://localhost/extensions-dummy/updateURL',
     'extensions.update.background.url': 'http://localhost/extensions-dummy/updateBackgroundURL',
     'extensions.blocklist.url' : 'http://localhost/extensions-dummy/blocklistURL',
     # Make sure opening about:addons won't hit the network.
     'extensions.webservice.discoverURL' : 'http://localhost/extensions-dummy/discoveryURL',
     'extensions.getAddons.maxResults': 0,
+    'extensions.shield-recipe-client@mozilla.org.api_url' : 'https://localhost/selfsupport-dummy/',
 
     # Disable webapp updates.  Yes, it is supposed to be an integer.
     'browser.webapps.checkForUpdates': 0,
 
     # Location services
     'geo.wifi.uri': 'http://localhost/location-dummy/locationURL',
     'browser.search.geoip.url': 'http://localhost/location-dummy/locationURL',
 
--- a/addon-sdk/source/test/preferences/no-connections.json
+++ b/addon-sdk/source/test/preferences/no-connections.json
@@ -24,16 +24,17 @@
   "browser.safebrowsing.provider.mozilla.updateURL": "http://localhost/safebrowsing-dummy/update",
   "browser.newtabpage.directory.source": "data:application/json,{'jetpack':1}",
   "browser.newtabpage.directory.ping": "",
   "extensions.update.url": "http://localhost/extensions-dummy/updateURL",
   "extensions.update.background.url": "http://localhost/extensions-dummy/updateBackgroundURL",
   "extensions.blocklist.url": "http://localhost/extensions-dummy/blocklistURL",
   "extensions.webservice.discoverURL": "http://localhost/extensions-dummy/discoveryURL",
   "extensions.getAddons.maxResults": 0,
+  "extensions.shield-recipe-client@mozilla.org.api_url": "https://localhost/selfsupport-dummy/",
   "services.blocklist.base": "http://localhost/dummy-kinto/v1",
   "geo.wifi.uri": "http://localhost/location-dummy/locationURL",
   "browser.search.geoip.url": "http://localhost/location-dummy/locationURL",
   "browser.search.isUS": true,
   "browser.search.countryCode": "US",
   "geo.wifi.uri": "http://localhost/extensions-dummy/geowifiURL",
   "geo.wifi.scan": false,
   "browser.webapps.checkForUpdates": 0,
--- a/browser/extensions/moz.build
+++ b/browser/extensions/moz.build
@@ -5,16 +5,17 @@
 # file, You can obtain one at http://mozilla.org/MPL/2.0/.
 
 DIRS += [
     'aushelper',
     'e10srollout',
     'pdfjs',
     'pocket',
     'webcompat',
+    'shield-recipe-client',
 ]
 
 # Only include the following system add-ons if building Aurora or Nightly
 if 'a' in CONFIG['GRE_MILESTONE']:
     DIRS += [
         'flyweb',
         'formautofill',
     ]
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/bootstrap.js
@@ -0,0 +1,11 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * 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/. */
+"use strict";
+
+const { utils: Cu } = Components;
+const rootURI = __SCRIPT_URI_SPEC__.replace("bootstrap.js", "");
+const COMMONJS_URI = "resource://gre/modules/commonjs";
+const { require } = Cu.import(COMMONJS_URI + "/toolkit/require.js", {});
+const { Bootstrap } = require(COMMONJS_URI + "/sdk/addon/bootstrap.js");
+var { startup, shutdown, install, uninstall } = new Bootstrap(rootURI);
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/chrome.manifest
@@ -0,0 +1,5 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# 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/.
+
+resource shield-recipe-client .
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/data/EventEmitter.js
@@ -0,0 +1,60 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * 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/. */
+
+// This file is meant to run inside action sandboxes
+
+"use strict";
+
+
+this.EventEmitter = function(driver) {
+  if (!driver) {
+    throw new Error("driver must be provided");
+  }
+
+  const listeners = {};
+
+  return {
+    emit(eventName, event) {
+      // Fire events async
+      Promise.resolve()
+        .then(() => {
+          if (!(eventName in listeners)) {
+            driver.log(`EventEmitter: Event fired with no listeners: ${eventName}`);
+            return;
+          }
+          // freeze event to prevent handlers from modifying it
+          let frozenEvent = Object.freeze(event);
+          // Clone callbacks array to avoid problems with mutation while iterating
+          const callbacks = Array.from(listeners[eventName]);
+          for (let cb of callbacks) {
+            cb(frozenEvent);
+          }
+        });
+    },
+
+    on(eventName, callback) {
+      if (!(eventName in listeners)) {
+        listeners[eventName] = [];
+      }
+      listeners[eventName].push(callback);
+    },
+
+    off(eventName, callback) {
+      if (eventName in listeners) {
+        let index = listeners[eventName].indexOf(callback);
+        if (index !== -1) {
+          listeners[eventName].splice(index, 1);
+        }
+      }
+    },
+
+    once(eventName, callback) {
+      const inner = event => {
+        callback(event);
+        this.off(eventName, inner);
+      };
+      this.on(eventName, inner);
+    },
+  };
+};
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/index.js
@@ -0,0 +1,40 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * 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/. */
+
+"use strict";
+
+const {Cu} = require("chrome");
+const {prefs} = require("sdk/simple-prefs");
+Cu.import("resource://shield-recipe-client/lib/Log.jsm");
+Cu.import("resource://shield-recipe-client/lib/RecipeRunner.jsm");
+Cu.import("resource://shield-recipe-client/lib/SelfRepairInteraction.jsm");
+Cu.import("resource://shield-recipe-client/lib/CleanupManager.jsm");
+
+/* Called when the addon is loaded. This includes browser startup, addon
+ * installation, and addon re-enabling. */
+exports.main = function() {
+
+  // Self Repair only checks its pref on start, so if we disable it, wait until
+  // next startup to run, unless the dev_mode preference is set.
+  if (SelfRepairInteraction.isEnabled()) {
+    if (prefs.dev_mode) {
+      SelfRepairInteraction.disableSelfRepair();
+      RecipeRunner.init();
+    } else {
+      SelfRepairInteraction.disableSelfRepair();
+      Log.info("Waiting until next startup to start recipe client. Set Developer Mode to prevent this behavior");
+    }
+  } else {
+    RecipeRunner.init();
+  }
+};
+
+// Called when Firefox is shut down, or when the addon is uninstalled or disabled.
+exports.onUnload = function(reason) {
+  CleanupManager.cleanup();
+
+  if (reason === "uninstall" || reason === "disable") {
+    SelfRepairInteraction.enableSelfRepair();
+  }
+};
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/install.rdf
@@ -0,0 +1,24 @@
+<?xml version="1.0" encoding="utf-8"?>
+<RDF xmlns="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:em="http://www.mozilla.org/2004/em-rdf#">
+    <Description about="urn:mozilla:install-manifest">
+          <em:id>shield-recipe-client@mozilla.org</em:id>
+          <em:type>2</em:type>
+          <em:bootstrap>true</em:bootstrap>
+          <em:unpack>false</em:unpack>
+          <em:version>0.1.3</em:version>
+          <em:name>Shield Recipe Client</em:name>
+          <em:description>Client to download and run recipes for SHIELD, Heartbeat, etc.</em:description>
+          <em:multiprocessCompatible>true</em:multiprocessCompatible>
+
+          <em:targetApplication>
+            <Description>
+              <em:id>{ec8030f7-c20a-464f-9b0e-13a3a9e97384}</em:id>
+              <em:minVersion>51.0a1</em:minVersion>
+              <em:maxVersion>*</em:maxVersion>
+</Description>
+</em:targetApplication>
+
+
+    </Description>
+
+</RDF>
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/lib/CleanupManager.jsm
@@ -0,0 +1,21 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * 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/. */
+
+"use strict";
+
+this.EXPORTED_SYMBOLS = ["CleanupManager"];
+
+const cleanupHandlers = [];
+
+this.CleanupManager = {
+  addCleanupHandler(handler) {
+    cleanupHandlers.push(handler);
+  },
+
+  cleanup() {
+    for (let handler of cleanupHandlers) {
+      handler();
+    }
+  },
+};
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/lib/EnvExpressions.jsm
@@ -0,0 +1,59 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * 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/. */
+
+"use strict";
+
+const {utils: Cu} = Components;
+// Cu.import("resource://gre/modules/TelemetryArchive.jsm");
+// Cu.import("resource://gre/modules/Task.jsm");
+// Cu.import("resource://shield-recipe-client/lib/Sampling.jsm");
+
+this.EXPORTED_SYMBOLS = ["EnvExpressions"];
+
+// const {Loader, Require} = Cu.import("resource://gre/modules/commonjs/toolkit/loader.js", {});
+// const loader = new Loader({
+//   paths: {
+//     "": "resource://shield-recipe-client-at-mozilla-dot-org/node_modules/",
+//   },
+// });
+// const require = new Require(loader, {});
+//
+// const {Jexl} = require("jexl/lib/Jexl.js");
+
+const getLatestTelemetry = Task.async(function *() {
+  let pings = yield TelemetryArchive.promiseArchivedPingList();
+
+  // get most recent ping per type
+  let mostRecentPings = {};
+  for (let ping of pings) {
+    if (ping.type in mostRecentPings) {
+      if (mostRecentPings[ping.type].timeStampCreated < ping.timeStampCreated) {
+        mostRecentPings[ping.type] = ping;
+      }
+    } else {
+      mostRecentPings[ping.type] = ping;
+    }
+  }
+
+  let telemetry = {};
+  for (let key in mostRecentPings) {
+    const ping = mostRecentPings[key];
+    telemetry[ping.type] = yield TelemetryArchive.promiseArchivedPingById(ping.id);
+  }
+  return telemetry;
+});
+
+const jexl = new Jexl();
+jexl.addTransforms({
+  date: dateString => new Date(dateString),
+  stableSample: Sampling.stableSample,
+});
+
+this.EnvExpressions = {
+  eval(expr, extraContext={}) {
+    const context = Object.assign({telemetry: getLatestTelemetry()}, extraContext);
+    const onelineExpr = expr.replace(/[\t\n\r]/g, " ");
+    return jexl.eval(onelineExpr, context);
+  },
+};
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/lib/Heartbeat.jsm
@@ -0,0 +1,345 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * 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/. */
+
+"use strict";
+
+const {utils: Cu} = Components;
+
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/TelemetryController.jsm");
+Cu.import("resource://gre/modules/Timer.jsm"); /* globals setTimeout, clearTimeout */
+Cu.import("resource://shield-recipe-client/lib/Log.jsm");
+Cu.import("resource://shield-recipe-client/lib/CleanupManager.jsm");
+
+Cu.importGlobalProperties(["URL"]); /* globals URL */
+
+this.EXPORTED_SYMBOLS = ["Heartbeat"];
+
+const PREF_SURVEY_DURATION = "browser.uitour.surveyDuration";
+const NOTIFICATION_TIME = 3000;
+
+/**
+ * Show the Heartbeat UI to request user feedback.
+ *
+ * @param chromeWindow
+ *        The chrome window that the heartbeat notification is displayed in.
+ * @param eventEmitter
+ *        An EventEmitter instance to report status to.
+ * @param sandboxManager
+ *        The manager for the sandbox this was called from. Heartbeat will
+ *        increment the hold counter on the manager.
+ * @param {Object} options Options object.
+ * @param {String} options.message
+ *        The message, or question, to display on the notification.
+ * @param {String} options.thanksMessage
+ *        The thank you message to display after user votes.
+ * @param {String} options.flowId
+ *        An identifier for this rating flow. Please note that this is only used to
+ *        identify the notification box.
+ * @param {String} [options.engagementButtonLabel=null]
+ *        The text of the engagement button to use instad of stars. If this is null
+ *        or invalid, rating stars are used.
+ * @param {String} [options.learnMoreMessage=null]
+ *        The label of the learn more link. No link will be shown if this is null.
+ * @param {String} [options.learnMoreUrl=null]
+ *        The learn more URL to open when clicking on the learn more link. No learn more
+ *        will be shown if this is an invalid URL.
+ * @param {String} [options.surveyId]
+ *        An ID for the survey, reflected in the Telemetry ping.
+ * @param {Number} [options.surveyVersion]
+ *        Survey's version number, reflected in the Telemetry ping.
+ * @param {boolean} [options.testing]
+ *        Whether this is a test survey, reflected in the Telemetry ping.
+ * @param {String} [options.postAnswerURL=null]
+ *        The url to visit after the user answers the question.
+ */
+this.Heartbeat = class {
+  constructor(chromeWindow, eventEmitter, sandboxManager, options) {
+    if (typeof options.flowId !== "string") {
+      throw new Error("flowId must be a string");
+    }
+
+    if (!options.flowId) {
+      throw new Error("flowId must not be an empty string");
+    }
+
+    if (typeof options.message !== "string") {
+      throw new Error("message must be a string");
+    }
+
+    if (!options.message) {
+      throw new Error("message must not be an empty string");
+    }
+
+    if (!sandboxManager) {
+      throw new Error("sandboxManager must be provided");
+    }
+
+    if (options.postAnswerUrl) {
+      options.postAnswerUrl = new URL(options.postAnswerUrl);
+    } else {
+      options.postAnswerUrl = null;
+    }
+
+    if (options.learnMoreUrl) {
+      try {
+        options.learnMoreUrl = new URL(options.learnMoreUrl);
+      } catch (e) {
+        options.learnMoreUrl = null;
+      }
+    }
+
+    this.chromeWindow = chromeWindow;
+    this.eventEmitter = eventEmitter;
+    this.sandboxManager = sandboxManager;
+    this.options = options;
+    this.surveyResults = {};
+    this.buttons = null;
+
+    // so event handlers are consistent
+    this.handleWindowClosed = this.handleWindowClosed.bind(this);
+
+    if (this.options.engagementButtonLabel) {
+      this.buttons = [{
+        label: this.options.engagementButtonLabel,
+        callback: () => {
+          // Let the consumer know user engaged.
+          this.maybeNotifyHeartbeat("Engaged");
+
+          this.userEngaged({
+            type: "button",
+            flowId: this.options.flowId,
+          });
+
+          // Return true so that the notification bar doesn't close itself since
+          // we have a thank you message to show.
+          return true;
+        },
+      }];
+    }
+
+    this.notificationBox = this.chromeWindow.document.querySelector("#high-priority-global-notificationbox");
+    this.notice = this.notificationBox.appendNotification(
+      this.options.message,
+      "heartbeat-" + this.options.flowId,
+      "chrome://browser/skin/heartbeat-icon.svg",
+      this.notificationBox.PRIORITY_INFO_HIGH,
+      this.buttons,
+      eventType => {
+        if (eventType !== "removed") {
+          return;
+        }
+        this.maybeNotifyHeartbeat("NotificationClosed");
+      }
+    );
+
+    // Holds the rating UI
+    let frag = this.chromeWindow.document.createDocumentFragment();
+
+    // Build the heartbeat stars
+    if (!this.options.engagementButtonLabel) {
+      const numStars = this.options.engagementButtonLabel ? 0 : 5;
+      let ratingContainer = this.chromeWindow.document.createElement("hbox");
+      ratingContainer.id = "star-rating-container";
+
+      for (let i = 0; i < numStars; i++) {
+        // create a star rating element
+        let ratingElement = this.chromeWindow.document.createElement("toolbarbutton");
+
+        // style it
+        let starIndex = numStars - i;
+        ratingElement.className = "plain star-x";
+        ratingElement.id = "star" + starIndex;
+        ratingElement.setAttribute("data-score", starIndex);
+
+        // Add the click handler
+        ratingElement.addEventListener("click", ev => {
+          let rating = parseInt(ev.target.getAttribute("data-score"));
+          this.maybeNotifyHeartbeat("Voted", {score: rating});
+          this.userEngaged({type: "stars", score: rating, flowId: this.options.flowId});
+        });
+
+        ratingContainer.appendChild(ratingElement);
+      }
+
+      frag.appendChild(ratingContainer);
+    }
+
+    this.messageImage = this.chromeWindow.document.getAnonymousElementByAttribute(this.notice, "anonid", "messageImage");
+    this.messageImage.classList.add("heartbeat", "pulse-onshow");
+
+    this.messageText = this.chromeWindow.document.getAnonymousElementByAttribute(this.notice, "anonid", "messageText");
+    this.messageText.classList.add("heartbeat");
+
+    // Make sure the stars are not pushed to the right by the spacer.
+    let rightSpacer = this.chromeWindow.document.createElement("spacer");
+    rightSpacer.flex = 20;
+    frag.appendChild(rightSpacer);
+
+    // collapse the space before the stars
+    this.messageText.flex = 0;
+    let leftSpacer = this.messageText.nextSibling;
+    leftSpacer.flex = 0;
+
+    // Add Learn More Link
+    if (this.options.learnMoreMessage && this.options.learnMoreUrl) {
+      let learnMore = this.chromeWindow.document.createElement("label");
+      learnMore.className = "text-link";
+      learnMore.href = this.options.learnMoreUrl.toString();
+      learnMore.setAttribute("value", this.options.learnMoreMessage);
+      learnMore.addEventListener("click", () => this.maybeNotifyHeartbeat("LearnMore"));
+      frag.appendChild(learnMore);
+    }
+
+    // Append the fragment and apply the styling
+    this.notice.appendChild(frag);
+    this.notice.classList.add("heartbeat");
+
+    // Let the consumer know the notification was shown.
+    this.maybeNotifyHeartbeat("NotificationOffered");
+    this.chromeWindow.addEventListener("SSWindowClosing", this.handleWindowClosed);
+
+    let surveyDuration = Services.prefs.getIntPref(PREF_SURVEY_DURATION) * 1000;
+    this.surveyEndTimer = setTimeout(() => {
+      this.maybeNotifyHeartbeat("SurveyExpired");
+      this.close();
+    }, surveyDuration);
+
+    this.sandboxManager.addHold("heartbeat");
+    CleanupManager.addCleanupHandler(() => this.close());
+  }
+
+  maybeNotifyHeartbeat(name, data={}) {
+    if (this.pingSent) {
+      Log.warn("Heartbeat event recieved after Telemetry ping sent. name:", name, "data:", data);
+      return;
+    }
+
+    let timestamp = Date.now();
+    let sendPing = false;
+    let cleanup = false;
+
+    let phases = {
+      NotificationOffered: () => {
+        this.surveyResults.flowId = this.options.flowId;
+        this.surveyResults.offeredTS = timestamp;
+      },
+      LearnMore: () => {
+        if (!this.surveyResults.learnMoreTS) {
+          this.surveyResults.learnMoreTS = timestamp;
+        }
+      },
+      Engaged: () => {
+        this.surveyResults.engagedTS = timestamp;
+      },
+      Voted: () => {
+        this.surveyResults.votedTS = timestamp;
+        this.surveyResults.score = data.score;
+      },
+      SurveyExpired: () => {
+        this.surveyResults.expiredTS = timestamp;
+      },
+      NotificationClosed: () => {
+        this.surveyResults.closedTS = timestamp;
+        cleanup = true;
+        sendPing = true;
+      },
+      WindowClosed: () => {
+        this.surveyResults.windowClosedTS = timestamp;
+        cleanup = true;
+        sendPing = true;
+      },
+      default: () => {
+        Log.error("Unrecognized Heartbeat event:", name);
+      },
+    };
+
+    (phases[name] || phases.default)();
+
+    data.timestamp = timestamp;
+    data.flowId = this.options.flowId;
+    this.eventEmitter.emit(name, Cu.cloneInto(data, this.sandboxManager.sandbox));
+
+    if (sendPing) {
+      // Send the ping to Telemetry
+      let payload = Object.assign({version: 1}, this.surveyResults);
+      for (let meta of ["surveyId", "surveyVersion", "testing"]) {
+        if (this.options.hasOwnProperty(meta)) {
+          payload[meta] = this.options[meta];
+        }
+      }
+
+      Log.debug("Sending telemetry");
+      TelemetryController.submitExternalPing("heartbeat", payload, {
+        addClientId: true,
+        addEnvironment: true,
+      });
+
+      // only for testing
+      this.eventEmitter.emit("TelemetrySent", Cu.cloneInto(payload, this.sandboxManager.sandbox));
+
+      // Survey is complete, clear out the expiry timer & survey configuration
+      if (this.surveyEndTimer) {
+        clearTimeout(this.surveyEndTimer);
+        this.surveyEndTimer = null;
+      }
+
+      this.pingSent = true;
+      this.surveyResults = null;
+    }
+
+    if (cleanup) {
+      this.cleanup();
+    }
+  }
+
+  userEngaged(engagementParams) {
+    // Make the heartbeat icon pulse twice
+    this.notice.label = this.options.thanksMessage;
+    this.messageImage.classList.remove("pulse-onshow");
+    this.messageImage.classList.add("pulse-twice");
+
+    // Remove all the children of the notice (rating container, and the flex)
+    while (this.notice.firstChild) {
+      this.notice.firstChild.remove();
+    }
+
+    // Open the engagement tab if we have a valid engagement URL.
+    if (this.options.postAnswerUrl) {
+      for (let key in engagementParams) {
+        this.options.postAnswerUrl.searchParams.append(key, engagementParams[key]);
+      }
+      // Open the engagement URL in a new tab.
+      this.chromeWindow.gBrowser.selectedTab = this.chromeWindow.gBrowser.addTab(this.options.postAnswerUrl.toString());
+    }
+
+    if (this.surveyEndTimer) {
+      clearTimeout(this.surveyEndTimer);
+      this.surveyEndTimer = null;
+    }
+
+    setTimeout(() => this.close(), NOTIFICATION_TIME);
+  }
+
+  handleWindowClosed() {
+    this.maybeNotifyHeartbeat("WindowClosed");
+  }
+
+  close() {
+    this.notificationBox.removeNotification(this.notice);
+    this.cleanup();
+  }
+
+  cleanup() {
+    this.sandboxManager.removeHold("heartbeat");
+    // remove listeners
+    this.chromeWindow.removeEventListener("SSWindowClosing", this.handleWindowClosed);
+    // remove references for garbage collection
+    this.chromeWindow = null;
+    this.notificationBox = null;
+    this.notification = null;
+    this.eventEmitter = null;
+    this.sandboxManager = null;
+  }
+};
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/lib/Log.jsm
@@ -0,0 +1,31 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * 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/. */
+
+"use strict";
+
+const {utils: Cu} = Components;
+Cu.import("resource://gre/modules/Console.jsm"); /* globals ConsoleAPI */
+
+this.EXPORTED_SYMBOLS = ["Log"];
+
+const LOG_LEVEL_PREF = "extensions.recipeclient.log_level";
+
+this.Log = new ConsoleAPI({
+  prefix: "RecipeRunner",
+  maxLogLevelPref: LOG_LEVEL_PREF,
+});
+
+/**
+ * Make a namespaced version of this logger which will prepend a string
+ * to all messages.
+ * @param  {String} namespace The string to prepend to messages. A colon
+ *                            and a space will be added to the end.
+ * @return {Object} A namespaced logger.
+ */
+this.Log.makeNamespace = function(namespace) {
+  return new ConsoleAPI({
+    prefix: "RecipeRunner." + namespace,
+    maxLogLevelPref: LOG_LEVEL_PREF,
+  });
+};
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/lib/NormandyApi.jsm
@@ -0,0 +1,95 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * 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/. */
+/* globals URLSearchParams */
+
+"use strict";
+
+const {utils: Cu, classes: Cc, interfaces: Ci} = Components;
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/Task.jsm");
+Cu.import("resource://gre/modules/CanonicalJSON.jsm");
+Cu.import("resource://shield-recipe-client/lib/Log.jsm");
+
+this.EXPORTED_SYMBOLS = ["NormandyApi"];
+
+const PREF_API_URL = "extensions.shield-recipe-client@mozilla.org.api_url";
+
+this.NormandyApi = {
+  apiCall(method, endpoint, data={}) {
+    const api_url = Services.prefs.getStringPref(PREF_API_URL);
+    let url = `${api_url}/${endpoint}`;
+    method = method.toLowerCase();
+
+    if (method === "get") {
+      if (data === {}) {
+        let paramObj = new URLSearchParams();
+        for (let key in data) {
+          paramObj.append(key, data[key]);
+        }
+        url += "?" + paramObj.toString();
+      }
+      data = undefined;
+    }
+
+    const headers = {"Accept": "application/json"};
+    return fetch(url, {
+      body: JSON.stringify(data),
+      headers,
+    });
+  },
+
+  get(endpoint, data) {
+    return this.apiCall("get", endpoint, data);
+  },
+
+  post(endpoint, data) {
+    return this.apiCall("post", endpoint, data);
+  },
+
+  fetchRecipes: Task.async(function* (filters={}) {
+    const rawText = yield (yield this.get("recipe/signed/", filters)).text();
+    const recipesWithSigs = JSON.parse(rawText);
+
+    const verifiedRecipes = [];
+
+    for (let {recipe, signature: {signature, x5u}} of recipesWithSigs) {
+      const serialized = CanonicalJSON.stringify(recipe);
+      if (!rawText.includes(serialized)) {
+        throw new Error("Canonical recipe serialization does not match!");
+      }
+
+      let certChain = yield (yield fetch(x5u)).text();
+      let builtSignature = `p384ecdsa=${signature}`;
+
+      const verifier = Cc["@mozilla.org/security/contentsignatureverifier;1"]
+        .createInstance(Ci.nsIContentSignatureVerifier);
+
+      if (!verifier.verifyContentSignature(serialized, builtSignature, certChain, "normandy.content-signature.mozilla.org")) {
+        throw new Error("Recipe signature is not valid");
+      }
+      verifiedRecipes.push(recipe);
+    }
+
+    Log.debug(`Fetched ${verifiedRecipes.length} recipes from the server:`, verifiedRecipes.map(r => r.name).join(", "));
+
+    return verifiedRecipes;
+  }),
+
+  /**
+   * Fetch metadata about this client determined by the server.
+   * @return {object} Metadata specified by the server
+   */
+  classifyClient() {
+    return this.get("classify_client/")
+      .then(response => response.json())
+      .then(clientData => {
+        clientData.request_time = new Date(clientData.request_time);
+        return clientData;
+      });
+  },
+
+  fetchAction(name) {
+    return this.get(`action/${name}/`).then(req => req.json());
+  },
+};
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/lib/NormandyDriver.jsm
@@ -0,0 +1,169 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * 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/. */
+
+"use strict";
+/* globals Components */
+
+const {classes: Cc, interfaces: Ci, utils: Cu} = Components;
+
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource:///modules/ShellService.jsm");
+Cu.import("resource://gre/modules/AddonManager.jsm");
+Cu.import("resource://gre/modules/Timer.jsm"); /* globals setTimeout, clearTimeout */
+Cu.import("resource://shield-recipe-client/lib/Log.jsm");
+Cu.import("resource://shield-recipe-client/lib/Storage.jsm");
+Cu.import("resource://shield-recipe-client/lib/Heartbeat.jsm");
+
+const {generateUUID} = Cc["@mozilla.org/uuid-generator;1"].getService(Ci.nsIUUIDGenerator);
+
+this.EXPORTED_SYMBOLS = ["NormandyDriver"];
+
+const PREF_INPUT_HOST = "extensions.shield-recipe-client@mozilla.org.input_host";
+const actionLogger = Log.makeNamespace("actions");
+
+this.NormandyDriver = function(sandboxManager, extraContext={}) {
+  if (!sandboxManager) {
+    throw new Error("sandboxManager is required");
+  }
+  const {sandbox} = sandboxManager;
+
+  return {
+    testing: false,
+
+    get locale() {
+      return Cc["@mozilla.org/chrome/chrome-registry;1"]
+        .getService(Ci.nsIXULChromeRegistry)
+        .getSelectedLocale("browser");
+    },
+
+    log(message, level="debug") {
+      let levels = ["debug", "info", "warn", "error"];
+      if (levels.indexOf(level) === -1) {
+        throw new Error(`Invalid log level "${level}"`);
+      }
+      actionLogger[level](message);
+    },
+
+    showHeartbeat(options) {
+      Log.info(`Showing heartbeat prompt "${options.message}"`);
+      let aWindow = Services.wm.getMostRecentWindow("navigator:browser");
+
+      if (!aWindow) {
+        return sandbox.Promise.reject(new sandbox.Error("No window to show heartbeat in"));
+      }
+
+      const sandboxedDriver = Cu.cloneInto(this, sandbox, {cloneFunctions: true});
+      const ee = new sandbox.EventEmitter(sandboxedDriver).wrappedJSObject;
+      const internalOptions = Object.assign({}, options, {testing: this.testing});
+      new Heartbeat(aWindow, ee, sandboxManager, internalOptions);
+      return sandbox.Promise.resolve(ee);
+    },
+
+    saveHeartbeatFlow(data) {
+      let defaultURL;
+      if (!this.testing) {
+        defaultURL = "https://input.mozilla.org/api/v2/hb/";
+      } else {
+        defaultURL = "https://input.allizom.org/api/v2/hb/";
+      }
+      const url = Services.prefs.getStringPref(PREF_INPUT_HOST, defaultURL);
+
+      let headers = {Accept: "application/json"};
+
+      Log.debug("Sending heartbeat flow data to Input", data);
+
+      // Make request to input
+      let p = fetch(url, {body: JSON.stringify(data), headers})
+        .then(response => response.text())
+        .then(responseText => {
+          Log.log("Input response:", responseText);
+          // Resolve undefined instead of passing the response down.
+          return undefined;
+        })
+        .catch(error => {
+          if (error.response) {
+            Log.error("Input error response:", error.response.json);
+          } else {
+            Log.error("Error sending heartbeat flow data to Input:", error);
+          }
+          throw new sandbox.Error(error.toString());
+        });
+      // Wrap the promise for the sandbox
+      return sandbox.Promise.resolve(p);
+    },
+
+    client() {
+      const appinfo = {
+        version: Services.appinfo.version,
+        channel: Services.appinfo.defaultUpdateChannel,
+        isDefaultBrowser: ShellService.isDefaultBrowser() || null,
+        searchEngine: null,
+        syncSetup: Services.prefs.prefHasUserValue("services.sync.username"),
+        plugins: {},
+        doNotTrack: Services.prefs.getBoolPref("privacy.donottrackheader.enabled"),
+      };
+
+      const searchEnginePromise = new Promise(resolve => {
+        Services.search.init(rv => {
+          if (Components.isSuccessCode(rv)) {
+            appinfo.searchEngine = Services.search.defaultEngine.identifier;
+          }
+          resolve();
+        });
+      });
+
+      const pluginsPromise = new Promise(resolve => {
+        AddonManager.getAddonsByTypes(["plugin"], plugins => {
+          plugins.forEach(plugin => appinfo.plugins[plugin.name] = plugin);
+          resolve();
+        });
+      });
+
+      return new sandbox.Promise(resolve => {
+        Promise.all([searchEnginePromise, pluginsPromise]).then(() => {
+          resolve(Cu.cloneInto(appinfo, sandbox));
+        });
+      });
+    },
+
+    uuid() {
+      let ret = generateUUID().toString();
+      ret = ret.slice(1, ret.length - 1);
+      return ret;
+    },
+
+    createStorage(keyPrefix) {
+      let storage;
+      try {
+        storage = Storage.makeStorage(keyPrefix, sandbox);
+      } catch (e) {
+        Log.error(e.stack);
+        throw e;
+      }
+      return storage;
+    },
+
+    location() {
+      let location = Cu.cloneInto({countryCode: extraContext.country}, sandbox);
+      return sandbox.Promise.resolve(location);
+    },
+
+    setTimeout(cb, time) {
+      if (typeof cb !== "function") {
+        throw new sandbox.Error(`setTimeout must be called with a function, got "${typeof cb}"`);
+      }
+      const token = setTimeout(() => {
+        cb();
+        sandboxManager.removeHold(`setTimeout-${token}`);
+      }, time);
+      sandboxManager.addHold(`setTimeout-${token}`);
+      return Cu.cloneInto(token, sandbox);
+    },
+
+    clearTimeout(token) {
+      clearTimeout(token);
+      sandboxManager.removeHold(`setTimeout-${token}`);
+    },
+  };
+};
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/lib/RecipeRunner.jsm
@@ -0,0 +1,163 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * 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/. */
+
+"use strict";
+
+const {utils: Cu} = Components;
+Cu.import("resource://gre/modules/Preferences.jsm");
+Cu.import("resource://gre/modules/Timer.jsm"); /* globals setTimeout */
+Cu.import("resource://gre/modules/Task.jsm");
+Cu.import("resource://shield-recipe-client/lib/Log.jsm");
+Cu.import("resource://shield-recipe-client/lib/NormandyDriver.jsm");
+Cu.import("resource://shield-recipe-client/lib/EnvExpressions.jsm");
+Cu.import("resource://shield-recipe-client/lib/NormandyApi.jsm");
+Cu.import("resource://shield-recipe-client/lib/SandboxManager.jsm");
+
+this.EXPORTED_SYMBOLS = ["RecipeRunner"];
+
+const PREF_API_URL = "extensions.shield-recipe-client@mozilla.org.api_url";
+const PREF_DEV_MODE = "extensions.shield-recipe-client@mozilla.org.dev_mode";
+const PREF_ENABLED = "extensions.shield-recipe-client@mozilla.org.enabled";
+const PREF_STARTUP_DELAY = "extensions.shield-recipe-client@mozilla.org.startup_delay";
+
+this.RecipeRunner = {
+  init() {
+    if (!this.checkPrefs()) {
+      return;
+    }
+
+    let delay;
+    if (Preferences.get(PREF_DEV_MODE)) {
+      delay = 0;
+    } else {
+      // startup delay is in seconds
+      delay = Preferences.get(PREF_STARTUP_DELAY) * 1000;
+    }
+
+    setTimeout(this.start.bind(this), delay);
+  },
+
+  checkPrefs() {
+    // Only run if Unified Telemetry is enabled.
+    if (!Preferences.get("toolkit.telemetry.unified", false)) {
+      Log.info("Disabling RecipeRunner because Unified Telemetry is disabled.");
+      return false;
+    }
+
+    if (Preferences.get(PREF_ENABLED)) {
+      Log.info("Recipe Client is disabled.");
+      return false;
+    }
+
+    const apiUrl = Preferences.get(PREF_API_URL);
+    if (!apiUrl.startsWith("https://")) {
+      Log.error(`Non HTTPS URL provided: ${apiUrl}`);
+      return false;
+    }
+
+    return true;
+  },
+
+  start: Task.async(function* () {
+    let recipes;
+    try {
+      recipes = yield NormandyApi.fetchRecipes({enabled: true});
+    } catch (e) {
+      const apiUrl = Preferences.get(PREF_API_URL);
+      Log.error(`Could not fetch recipes from ${apiUrl}: "${e}"`);
+      return;
+    }
+
+    let extraContext;
+    try {
+      extraContext = yield this.getExtraContext();
+    } catch (e) {
+      Log.warning(`Couldn't get extra filter context: ${e}`);
+      extraContext = {};
+    }
+
+    const recipesToRun = [];
+
+    for (let recipe of recipes) {
+      if (yield this.checkFilter(recipe, extraContext)) {
+        recipesToRun.push(recipe);
+      }
+    }
+
+    if (recipesToRun.length === 0) {
+      Log.debug("No recipes to execute");
+    } else {
+      for (let recipe of recipesToRun) {
+        try {
+          Log.debug(`Executing recipe "${recipe.name}" (action=${recipe.action})`);
+          yield this.executeRecipe(recipe, extraContext);
+        } catch (e) {
+          Log.error(`Could not execute recipe ${recipe.name}:`, e);
+        }
+      }
+    }
+  }),
+
+  getExtraContext() {
+    return NormandyApi.classifyClient()
+      .then(clientData => ({normandy: clientData}));
+  },
+
+  /**
+   * Evaluate a recipe's filter expression against the environment.
+   * @param {object} recipe
+   * @param {string} recipe.filter The expression to evaluate against the environment.
+   * @param {object} extraContext Any extra context to provide to the filter environment.
+   * @return {boolean} The result of evaluating the filter, cast to a bool.
+   */
+  checkFilter(recipe, extraContext) {
+    return EnvExpressions.eval(recipe.filter_expression, extraContext)
+      .then(result => {
+        return !!result;
+      })
+      .catch(error => {
+        Log.error(`Error checking filter for "${recipe.name}"`);
+        Log.error(`Filter: "${recipe.filter_expression}"`);
+        Log.error(`Error: "${error}"`);
+      });
+  },
+
+  /**
+   * Execute a recipe by fetching it action and executing it.
+   * @param  {Object} recipe A recipe to execute
+   * @promise Resolves when the action has executed
+   */
+  executeRecipe: Task.async(function* (recipe, extraContext) {
+    const sandboxManager = new SandboxManager();
+    const {sandbox} = sandboxManager;
+
+    let action = yield NormandyApi.fetchAction(recipe.action);
+    let response = yield fetch(action.implementation_url);
+
+    const actionScript = yield response.text();
+    const prepScript = `
+      var pendingAction = null;
+
+      function registerAction(name, Action) {
+        let a = new Action(sandboxedDriver, sandboxedRecipe);
+        pendingAction = a.execute()
+          .catch(err => sandboxedDriver.log(err, 'error'));
+      };
+
+      window.registerAction = registerAction;
+      window.setTimeout = sandboxedDriver.setTimeout;
+      window.clearTimeout = sandboxedDriver.clearTimeout;
+    `;
+
+    const driver = new NormandyDriver(sandboxManager, extraContext);
+    sandbox.sandboxedDriver = Cu.cloneInto(driver, sandbox, {cloneFunctions: true});
+    sandbox.sandboxedRecipe = Cu.cloneInto(recipe, sandbox);
+
+    Cu.evalInSandbox(prepScript, sandbox);
+    Cu.evalInSandbox(actionScript, sandbox);
+
+    sandboxManager.addHold("recipeExecution");
+    sandbox.pendingAction.then(() => sandboxManager.removeHold("recipeExecution"));
+  }),
+};
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/lib/Sampling.jsm
@@ -0,0 +1,79 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * 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/. */
+
+"use strict";
+
+const {utils: Cu} = Components;
+Cu.import("resource://shield-recipe-client/lib/Log.jsm");
+Cu.importGlobalProperties(["crypto", "TextEncoder"]);
+
+this.EXPORTED_SYMBOLS = ["Sampling"];
+
+/**
+ * Map from the range [0, 1] to [0, max(sha256)].
+ * @param  {number} frac A float from 0.0 to 1.0.
+ * @return {string} A 48 bit number represented in hex, padded to 12 characters.
+ */
+function fractionToKey(frac) {
+  const hashBits = 48;
+  const hashLength = hashBits / 4;
+
+  if (frac < 0 || frac > 1) {
+    throw new Error(`frac must be between 0 and 1 inclusive (got ${frac})`);
+  }
+
+  const mult = Math.pow(2, hashBits) - 1;
+  const inDecimal = Math.floor(frac * mult);
+  let hexDigits = inDecimal.toString(16);
+  if (hexDigits.length < hashLength) {
+    // Left pad with zeroes
+    // If N zeroes are needed, generate an array of nulls N+1 elements long,
+    // and inserts zeroes between each null.
+    hexDigits = Array(hashLength - hexDigits.length + 1).join("0") + hexDigits;
+  }
+
+  // Saturate at 2**48 - 1
+  if (hexDigits.length > hashLength) {
+    hexDigits = Array(hashLength + 1).join("f");
+  }
+
+  return hexDigits;
+}
+
+function bufferToHex(buffer) {
+  let hexCodes = [];
+  let view = new DataView(buffer);
+  for (let i = 0; i < view.byteLength; i += 4) {
+    // Using getUint32 reduces the number of iterations needed (we process 4 bytes each time)
+    let value = view.getUint32(i);
+    // toString(16) will give the hex representation of the number without padding
+    hexCodes.push(value.toString(16).padStart(8, "0"));
+  }
+
+  // Join all the hex strings into one
+  return hexCodes.join("");
+}
+
+this.Sampling = {
+  stableSample(input, rate) {
+    const hasher = crypto.subtle;
+
+    return hasher.digest("SHA-256", new TextEncoder("utf-8").encode(JSON.stringify(input)))
+      .then(hash => {
+        // truncate hash to 12 characters (2^48)
+        const inputHash = bufferToHex(hash).slice(0, 12);
+        const samplePoint = fractionToKey(rate);
+
+        if (samplePoint.length !== 12 || inputHash.length !== 12) {
+          throw new Error("Unexpected hash length");
+        }
+
+        return inputHash < samplePoint;
+
+      })
+      .catch(error => {
+        Log.error(`Error: ${error}`);
+      });
+  },
+};
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/lib/SandboxManager.jsm
@@ -0,0 +1,70 @@
+const {utils: Cu} = Components;
+Cu.import("resource://gre/modules/Services.jsm");
+
+this.EXPORTED_SYMBOLS = ["SandboxManager"];
+
+this.SandboxManager = class {
+  constructor() {
+    this._sandbox = makeSandbox();
+    this.holds = [];
+  }
+
+  get sandbox() {
+    if (this._sandbox) {
+      return this._sandbox;
+    }
+    throw new Error("Tried to use sandbox after it was nuked");
+  }
+
+  addHold(name) {
+    this.holds.push(name);
+  }
+
+  removeHold(name) {
+    let index = this.holds.indexOf(name);
+    if (index === -1) {
+      throw new Error(`Tried to remove non-existant hold "${name}"`);
+    }
+    this.holds.splice(index, 1);
+    this.tryCleanup();
+  }
+
+  tryCleanup() {
+    if (this.holds.length === 0) {
+      let sandbox = this._sandbox;
+      this._sandbox = null;
+      Cu.nukeSandbox(sandbox);
+    }
+  }
+
+  assertNuked(assert, done) {
+    // Do this in a promise, so other async things can resolve.
+    return new Promise(
+      (resolve, reject) => {
+        if (!this._sandbox) {
+          resolve();
+        } else {
+          reject(new Error(`Sandbox is not nuked. Holds left: ${this.holds}`));
+        }
+      })
+      .catch(err => {
+        assert.ok(false, err);
+      })
+      .then(done);
+  }
+};
+
+
+function makeSandbox() {
+  const sandbox = new Cu.Sandbox(null, {
+    wantComponents: false,
+    wantGlobalProperties: ["URL", "URLSearchParams"],
+  });
+
+  sandbox.window = Cu.cloneInto({}, sandbox);
+
+  const url = "resource://shield-recipe-client/data/EventEmitter.js";
+  Services.scriptloader.loadSubScript(url, sandbox);
+
+  return sandbox;
+}
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/lib/SelfRepairInteraction.jsm
@@ -0,0 +1,37 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * 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/. */
+
+"use strict";
+
+const {utils: Cu} = Components;
+Cu.import("resource://gre/modules/Preferences.jsm");
+Cu.import("resource://shield-recipe-client/lib/Log.jsm");
+
+this.EXPORTED_SYMBOLS = ["SelfRepairInteraction"];
+
+const PREF_SELF_SUPPORT_ENABLED = "browser.selfsupport.enabled";
+
+this.SelfRepairInteraction = {
+  enableSelfRepair() {
+    if (!this.isEnabled()) {
+      Log.info("Reenabling Self Repair");
+      this.setSelfRepair(true);
+    }
+  },
+
+  disableSelfRepair() {
+    if (this.isEnabled()) {
+      Log.info("Disabling Self Repair");
+      this.setSelfRepair(false);
+    }
+  },
+
+  setSelfRepair(enabled) {
+    Preferences.set(PREF_SELF_SUPPORT_ENABLED, enabled);
+  },
+
+  isEnabled() {
+    return Preferences.get(PREF_SELF_SUPPORT_ENABLED, true);
+  },
+};
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/lib/Storage.jsm
@@ -0,0 +1,133 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * 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/. */
+
+"use strict";
+
+const {utils: Cu} = Components;
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+Cu.import("resource://shield-recipe-client/lib/Log.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "JSONFile", "resource://gre/modules/JSONFile.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "OS", "resource://gre/modules/osfile.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Task", "resource://gre/modules/Task.jsm");
+
+this.EXPORTED_SYMBOLS = ["Storage"];
+
+let storePromise;
+
+function loadStorage() {
+  if (storePromise === undefined) {
+    let path = OS.Path.join(OS.Constants.Path.profileDir, "shield-recipe-client.json");
+    let storage = new JSONFile({path});
+    storePromise = Task.spawn(function* () {
+      yield storage.load();
+      return storage;
+    });
+  }
+  return storePromise;
+}
+
+this.Storage = {
+  makeStorage(prefix, sandbox) {
+    if (!sandbox) {
+      throw new Error("No sandbox passed");
+    }
+
+    const storageInterface = {
+      /**
+       * Sets an item in the prefixed storage.
+       * @returns {Promise}
+       * @resolves With the stored value, or null.
+       * @rejects Javascript exception.
+       */
+      getItem(keySuffix) {
+        return new sandbox.Promise((resolve, reject) => {
+          loadStorage()
+            .then(store => {
+              let namespace = store.data[prefix] || {};
+              const value = namespace[keySuffix] || null;
+              resolve(Cu.cloneInto(value, sandbox));
+            })
+            .catch(err => {
+              Log.error(err);
+              reject(new sandbox.Error());
+            });
+        });
+      },
+
+      /**
+       * Sets an item in the prefixed storage.
+       * @returns {Promise}
+       * @resolves When the operation is completed succesfully
+       * @rejects Javascript exception.
+       */
+      setItem(keySuffix, value) {
+        return new sandbox.Promise((resolve, reject) => {
+          loadStorage()
+            .then(store => {
+              if (!(prefix in store.data)) {
+                store.data[prefix] = {};
+              }
+              store.data[prefix][keySuffix] = value;
+              store.saveSoon();
+              resolve();
+            })
+            .catch(err => {
+              Log.error(err);
+              reject(new sandbox.Error());
+            });
+        });
+      },
+
+      /**
+       * Removes a single item from the prefixed storage.
+       * @returns {Promise}
+       * @resolves When the operation is completed succesfully
+       * @rejects Javascript exception.
+       */
+      removeItem(keySuffix) {
+        return new sandbox.Promise((resolve, reject) => {
+          loadStorage()
+            .then(store => {
+              if (!(prefix in store.data)) {
+                return;
+              }
+              delete store.data[prefix][keySuffix];
+              store.saveSoon();
+              resolve();
+            })
+            .catch(err => {
+              Log.error(err);
+              reject(new sandbox.Error());
+            });
+        });
+      },
+
+      /**
+       * Clears all storage for the prefix.
+       * @returns {Promise}
+       * @resolves When the operation is completed succesfully
+       * @rejects Javascript exception.
+       */
+      clear() {
+        return new sandbox.Promise((resolve, reject) => {
+          return loadStorage()
+            .then(store => {
+              store.data[prefix] = {};
+              store.saveSoon();
+              resolve();
+            })
+            .catch(err => {
+              Log.error(err);
+              reject(new sandbox.Error());
+            });
+        });
+      },
+    };
+
+    return Cu.cloneInto(storageInterface, sandbox, {
+      cloneFunctions: true,
+    });
+  },
+};
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/moz.build
@@ -0,0 +1,21 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# 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/.
+
+DEFINES['MOZ_APP_VERSION'] = CONFIG['MOZ_APP_VERSION']
+DEFINES['MOZ_APP_MAXVERSION'] = CONFIG['MOZ_APP_MAXVERSION']
+
+FINAL_TARGET_FILES.features['shield-recipe-client@mozilla.org'] += [
+  'bootstrap.js',
+  'index.js',
+  'install.rdf',
+  'lib',
+  'node_modules',
+  'package.json',
+  'test',
+]
+
+JETPACK_PACKAGE_MANIFESTS += ['test/jetpack-package.ini']
+
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/node_modules/jexl/LICENSE.txt
@@ -0,0 +1,19 @@
+Copyright (c) 2015 TechnologyAdvice
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
\ No newline at end of file
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/node_modules/jexl/lib/Jexl.js
@@ -0,0 +1,225 @@
+/*
+ * Jexl
+ * Copyright (c) 2015 TechnologyAdvice
+ */
+
+var Evaluator = require('./evaluator/Evaluator'),
+	Lexer = require('./Lexer'),
+	Parser = require('./parser/Parser'),
+	defaultGrammar = require('./grammar').elements;
+
+/**
+ * Jexl is the Javascript Expression Language, capable of parsing and
+ * evaluating basic to complex expression strings, combined with advanced
+ * xpath-like drilldown into native Javascript objects.
+ * @constructor
+ */
+function Jexl() {
+	this._customGrammar = null;
+	this._lexer = null;
+	this._transforms = {};
+}
+
+/**
+ * Adds a binary operator to Jexl at the specified precedence. The higher the
+ * precedence, the earlier the operator is applied in the order of operations.
+ * For example, * has a higher precedence than +, because multiplication comes
+ * before division.
+ *
+ * Please see grammar.js for a listing of all default operators and their
+ * precedence values in order to choose the appropriate precedence for the
+ * new operator.
+ * @param {string} operator The operator string to be added
+ * @param {number} precedence The operator's precedence
+ * @param {function} fn A function to run to calculate the result. The function
+ *      will be called with two arguments: left and right, denoting the values
+ *      on either side of the operator. It should return either the resulting
+ *      value, or a Promise that resolves with the resulting value.
+ */
+Jexl.prototype.addBinaryOp = function(operator, precedence, fn) {
+	this._addGrammarElement(operator, {
+		type: 'binaryOp',
+		precedence: precedence,
+		eval: fn
+	});
+};
+
+/**
+ * Adds a unary operator to Jexl. Unary operators are currently only supported
+ * on the left side of the value on which it will operate.
+ * @param {string} operator The operator string to be added
+ * @param {function} fn A function to run to calculate the result. The function
+ *      will be called with one argument: the literal value to the right of the
+ *      operator. It should return either the resulting value, or a Promise
+ *      that resolves with the resulting value.
+ */
+Jexl.prototype.addUnaryOp = function(operator, fn) {
+	this._addGrammarElement(operator, {
+		type: 'unaryOp',
+		weight: Infinity,
+		eval: fn
+	});
+};
+
+/**
+ * Adds or replaces a transform function in this Jexl instance.
+ * @param {string} name The name of the transform function, as it will be used
+ *      within Jexl expressions
+ * @param {function} fn The function to be executed when this transform is
+ *      invoked.  It will be provided with two arguments:
+ *          - {*} value: The value to be transformed
+ *          - {{}} args: The arguments for this transform
+ *          - {function} cb: A callback function to be called with an error
+ *            if the transform fails, or a null first argument and the
+ *            transformed value as the second argument on success.
+ */
+Jexl.prototype.addTransform = function(name, fn) {
+	this._transforms[name] = fn;
+};
+
+/**
+ * Syntactic sugar for calling {@link #addTransform} repeatedly.  This function
+ * accepts a map of one or more transform names to their transform function.
+ * @param {{}} map A map of transform names to transform functions
+ */
+Jexl.prototype.addTransforms = function(map) {
+	for (var key in map) {
+		if (map.hasOwnProperty(key))
+			this._transforms[key] = map[key];
+	}
+};
+
+/**
+ * Retrieves a previously set transform function.
+ * @param {string} name The name of the transform function
+ * @returns {function} The transform function
+ */
+Jexl.prototype.getTransform = function(name) {
+	return this._transforms[name];
+};
+
+/**
+ * Evaluates a Jexl string within an optional context.
+ * @param {string} expression The Jexl expression to be evaluated
+ * @param {Object} [context] A mapping of variables to values, which will be
+ *      made accessible to the Jexl expression when evaluating it
+ * @param {function} [cb] An optional callback function to be executed when
+ *      evaluation is complete.  It will be supplied with two arguments:
+ *          - {Error|null} err: Present if an error occurred
+ *          - {*} result: The result of the evaluation
+ * @returns {Promise<*>} resolves with the result of the evaluation.  Note that
+ *      if a callback is supplied, the returned promise will already have
+ *      a '.catch' attached to it in order to pass the error to the callback.
+ */
+Jexl.prototype.eval = function(expression, context, cb) {
+	if (typeof context === 'function') {
+		cb = context;
+		context = {};
+	}
+	else if (!context)
+		context = {};
+	var valPromise = this._eval(expression, context);
+	if (cb) {
+		// setTimeout is used for the callback to break out of the Promise's
+		// try/catch in case the callback throws.
+		var called = false;
+		return valPromise.then(function(val) {
+			called = true;
+			setTimeout(cb.bind(null, null, val), 0);
+		}).catch(function(err) {
+			if (!called)
+				setTimeout(cb.bind(null, err), 0);
+		});
+	}
+	return valPromise;
+};
+
+/**
+ * Removes a binary or unary operator from the Jexl grammar.
+ * @param {string} operator The operator string to be removed
+ */
+Jexl.prototype.removeOp = function(operator) {
+	var grammar = this._getCustomGrammar();
+	if (grammar[operator] && (grammar[operator].type == 'binaryOp' ||
+			grammar[operator].type == 'unaryOp')) {
+		delete grammar[operator];
+		this._lexer = null;
+	}
+};
+
+/**
+ * Adds an element to the grammar map used by this Jexl instance, cloning
+ * the default grammar first if necessary.
+ * @param {string} str The key string to be added
+ * @param {{type: <string>}} obj A map of configuration options for this
+ *      grammar element
+ * @private
+ */
+Jexl.prototype._addGrammarElement = function(str, obj) {
+	var grammar = this._getCustomGrammar();
+	grammar[str] = obj;
+	this._lexer = null;
+};
+
+/**
+ * Evaluates a Jexl string in the given context.
+ * @param {string} exp The Jexl expression to be evaluated
+ * @param {Object} [context] A mapping of variables to values, which will be
+ *      made accessible to the Jexl expression when evaluating it
+ * @returns {Promise<*>} resolves with the result of the evaluation.
+ * @private
+ */
+Jexl.prototype._eval = function(exp, context) {
+	var self = this,
+		grammar = this._getGrammar(),
+		parser = new Parser(grammar),
+		evaluator = new Evaluator(grammar, this._transforms, context);
+	return Promise.resolve().then(function() {
+		parser.addTokens(self._getLexer().tokenize(exp));
+		return evaluator.eval(parser.complete());
+	});
+};
+
+/**
+ * Gets the custom grammar object, creating it first if necessary. New custom
+ * grammars are created by executing a shallow clone of the default grammar
+ * map. The returned map is available to be changed.
+ * @returns {{}} a customizable grammar map.
+ * @private
+ */
+Jexl.prototype._getCustomGrammar = function() {
+	if (!this._customGrammar) {
+		this._customGrammar = {};
+		for (var key in defaultGrammar) {
+			if (defaultGrammar.hasOwnProperty(key))
+				this._customGrammar[key] = defaultGrammar[key];
+		}
+	}
+	return this._customGrammar;
+};
+
+/**
+ * Gets the grammar map currently being used by Jexl; either the default map,
+ * or a locally customized version. The returned map should never be changed
+ * in any way.
+ * @returns {{}} the grammar map currently in use.
+ * @private
+ */
+Jexl.prototype._getGrammar = function() {
+	return this._customGrammar || defaultGrammar;
+};
+
+/**
+ * Gets a Lexer instance as a singleton in reference to this Jexl instance.
+ * @returns {Lexer} an instance of Lexer, initialized with a grammar
+ *      appropriate to this Jexl instance.
+ * @private
+ */
+Jexl.prototype._getLexer = function() {
+	if (!this._lexer)
+		this._lexer = new Lexer(this._getGrammar());
+	return this._lexer;
+};
+
+module.exports = new Jexl();
+module.exports.Jexl = Jexl;
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/node_modules/jexl/lib/Lexer.js
@@ -0,0 +1,244 @@
+/*
+ * Jexl
+ * Copyright (c) 2015 TechnologyAdvice
+ */
+
+var numericRegex = /^-?(?:(?:[0-9]*\.[0-9]+)|[0-9]+)$/,
+	identRegex = /^[a-zA-Z_\$][a-zA-Z0-9_\$]*$/,
+	escEscRegex = /\\\\/,
+	preOpRegexElems = [
+		// Strings
+		"'(?:(?:\\\\')?[^'])*'",
+		'"(?:(?:\\\\")?[^"])*"',
+		// Whitespace
+		'\\s+',
+		// Booleans
+		'\\btrue\\b',
+		'\\bfalse\\b'
+	],
+	postOpRegexElems = [
+		// Identifiers
+		'\\b[a-zA-Z_\\$][a-zA-Z0-9_\\$]*\\b',
+		// Numerics (without negative symbol)
+		'(?:(?:[0-9]*\\.[0-9]+)|[0-9]+)'
+	],
+	minusNegatesAfter = ['binaryOp', 'unaryOp', 'openParen', 'openBracket',
+		'question', 'colon'];
+
+/**
+ * Lexer is a collection of stateless, statically-accessed functions for the
+ * lexical parsing of a Jexl string.  Its responsibility is to identify the
+ * "parts of speech" of a Jexl expression, and tokenize and label each, but
+ * to do only the most minimal syntax checking; the only errors the Lexer
+ * should be concerned with are if it's unable to identify the utility of
+ * any of its tokens.  Errors stemming from these tokens not being in a
+ * sensible configuration should be left for the Parser to handle.
+ * @type {{}}
+ */
+function Lexer(grammar) {
+	this._grammar = grammar;
+}
+
+/**
+ * Splits a Jexl expression string into an array of expression elements.
+ * @param {string} str A Jexl expression string
+ * @returns {Array<string>} An array of substrings defining the functional
+ *      elements of the expression.
+ */
+Lexer.prototype.getElements = function(str) {
+	var regex = this._getSplitRegex();
+	return str.split(regex).filter(function(elem) {
+		// Remove empty strings
+		return elem;
+	});
+};
+
+/**
+ * Converts an array of expression elements into an array of tokens.  Note that
+ * the resulting array may not equal the element array in length, as any
+ * elements that consist only of whitespace get appended to the previous
+ * token's "raw" property.  For the structure of a token object, please see
+ * {@link Lexer#tokenize}.
+ * @param {Array<string>} elements An array of Jexl expression elements to be
+ *      converted to tokens
+ * @returns {Array<{type, value, raw}>} an array of token objects.
+ */
+Lexer.prototype.getTokens = function(elements) {
+	var tokens = [],
+		negate = false;
+	for (var i = 0; i < elements.length; i++) {
+		if (this._isWhitespace(elements[i])) {
+			if (tokens.length)
+				tokens[tokens.length - 1].raw += elements[i];
+		}
+		else if (elements[i] === '-' && this._isNegative(tokens))
+			negate = true;
+		else {
+			if (negate) {
+				elements[i] = '-' + elements[i];
+				negate = false;
+			}
+			tokens.push(this._createToken(elements[i]));
+		}
+	}
+	// Catch a - at the end of the string. Let the parser handle that issue.
+	if (negate)
+		tokens.push(this._createToken('-'));
+	return tokens;
+};
+
+/**
+ * Converts a Jexl string into an array of tokens.  Each token is an object
+ * in the following format:
+ *
+ *     {
+ *         type: <string>,
+ *         [name]: <string>,
+ *         value: <boolean|number|string>,
+ *         raw: <string>
+ *     }
+ *
+ * Type is one of the following:
+ *
+ *      literal, identifier, binaryOp, unaryOp
+ *
+ * OR, if the token is a control character its type is the name of the element
+ * defined in the Grammar.
+ *
+ * Name appears only if the token is a control string found in
+ * {@link grammar#elements}, and is set to the name of the element.
+ *
+ * Value is the value of the token in the correct type (boolean or numeric as
+ * appropriate). Raw is the string representation of this value taken directly
+ * from the expression string, including any trailing spaces.
+ * @param {string} str The Jexl string to be tokenized
+ * @returns {Array<{type, value, raw}>} an array of token objects.
+ * @throws {Error} if the provided string contains an invalid token.
+ */
+Lexer.prototype.tokenize = function(str) {
+	var elements = this.getElements(str);
+	return this.getTokens(elements);
+};
+
+/**
+ * Creates a new token object from an element of a Jexl string. See
+ * {@link Lexer#tokenize} for a description of the token object.
+ * @param {string} element The element from which a token should be made
+ * @returns {{value: number|boolean|string, [name]: string, type: string,
+ *      raw: string}} a token object describing the provided element.
+ * @throws {Error} if the provided string is not a valid expression element.
+ * @private
+ */
+Lexer.prototype._createToken = function(element) {
+	var token = {
+		type: 'literal',
+		value: element,
+		raw: element
+	};
+	if (element[0] == '"' || element[0] == "'")
+		token.value = this._unquote(element);
+	else if (element.match(numericRegex))
+		token.value = parseFloat(element);
+	else if (element === 'true' || element === 'false')
+		token.value = element === 'true';
+	else if (this._grammar[element])
+		token.type = this._grammar[element].type;
+	else if (element.match(identRegex))
+		token.type = 'identifier';
+	else
+		throw new Error("Invalid expression token: " + element);
+	return token;
+};
+
+/**
+ * Escapes a string so that it can be treated as a string literal within a
+ * regular expression.
+ * @param {string} str The string to be escaped
+ * @returns {string} the RegExp-escaped string.
+ * @see https://developer.mozilla.org/en/docs/Web/JavaScript/Guide/Regular_Expressions
+ * @private
+ */
+Lexer.prototype._escapeRegExp = function(str) {
+	str = str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+	if (str.match(identRegex))
+		str = '\\b' + str + '\\b';
+	return str;
+};
+
+/**
+ * Gets a RegEx object appropriate for splitting a Jexl string into its core
+ * elements.
+ * @returns {RegExp} An element-splitting RegExp object
+ * @private
+ */
+Lexer.prototype._getSplitRegex = function() {
+	if (!this._splitRegex) {
+		var elemArray = Object.keys(this._grammar);
+		// Sort by most characters to least, then regex escape each
+		elemArray = elemArray.sort(function(a ,b) {
+			return b.length - a.length;
+		}).map(function(elem) {
+			return this._escapeRegExp(elem);
+		}, this);
+		this._splitRegex = new RegExp('(' + [
+			preOpRegexElems.join('|'),
+			elemArray.join('|'),
+			postOpRegexElems.join('|')
+		].join('|') + ')');
+	}
+	return this._splitRegex;
+};
+
+/**
+ * Determines whether the addition of a '-' token should be interpreted as a
+ * negative symbol for an upcoming number, given an array of tokens already
+ * processed.
+ * @param {Array<Object>} tokens An array of tokens already processed
+ * @returns {boolean} true if adding a '-' should be considered a negative
+ *      symbol; false otherwise
+ * @private
+ */
+Lexer.prototype._isNegative = function(tokens) {
+	if (!tokens.length)
+		return true;
+	return minusNegatesAfter.some(function(type) {
+		return type === tokens[tokens.length - 1].type;
+	});
+};
+
+/**
+ * A utility function to determine if a string consists of only space
+ * characters.
+ * @param {string} str A string to be tested
+ * @returns {boolean} true if the string is empty or consists of only spaces;
+ *      false otherwise.
+ * @private
+ */
+Lexer.prototype._isWhitespace = function(str) {
+	for (var i = 0; i < str.length; i++) {
+		if (str[i] != ' ')
+			return false;
+	}
+	return true;
+};
+
+/**
+ * Removes the beginning and trailing quotes from a string, unescapes any
+ * escaped quotes on its interior, and unescapes any escaped escape characters.
+ * Note that this function is not defensive; it assumes that the provided
+ * string is not empty, and that its first and last characters are actually
+ * quotes.
+ * @param {string} str A string whose first and last characters are quotes
+ * @returns {string} a string with the surrounding quotes stripped and escapes
+ *      properly processed.
+ * @private
+ */
+Lexer.prototype._unquote = function(str) {
+	var quote = str[0],
+		escQuoteRegex = new RegExp('\\\\' + quote, 'g');
+	return str.substr(1, str.length - 2)
+		.replace(escQuoteRegex, quote)
+		.replace(escEscRegex, '\\');
+};
+
+module.exports = Lexer;
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/node_modules/jexl/lib/evaluator/Evaluator.js
@@ -0,0 +1,153 @@
+/*
+ * Jexl
+ * Copyright (c) 2015 TechnologyAdvice
+ */
+
+var handlers = require('./handlers');
+
+/**
+ * The Evaluator takes a Jexl expression tree as generated by the
+ * {@link Parser} and calculates its value within a given context. The
+ * collection of transforms, context, and a relative context to be used as the
+ * root for relative identifiers, are all specific to an Evaluator instance.
+ * When any of these things change, a new instance is required.  However, a
+ * single instance can be used to simultaneously evaluate many different
+ * expressions, and does not have to be reinstantiated for each.
+ * @param {{}} grammar A grammar map against which to evaluate the expression
+ *      tree
+ * @param {{}} [transforms] A map of transform names to transform functions. A
+ *      transform function takes two arguments:
+ *          - {*} val: A value to be transformed
+ *          - {{}} args: A map of argument keys to their evaluated values, as
+ *              specified in the expression string
+ *      The transform function should return either the transformed value, or
+ *      a Promises/A+ Promise object that resolves with the value and rejects
+ *      or throws only when an unrecoverable error occurs. Transforms should
+ *      generally return undefined when they don't make sense to be used on the
+ *      given value type, rather than throw/reject. An error is only
+ *      appropriate when the transform would normally return a value, but
+ *      cannot due to some other failure.
+ * @param {{}} [context] A map of variable keys to their values. This will be
+ *      accessed to resolve the value of each non-relative identifier. Any
+ *      Promise values will be passed to the expression as their resolved
+ *      value.
+ * @param {{}|Array<{}|Array>} [relativeContext] A map or array to be accessed
+ *      to resolve the value of a relative identifier.
+ * @constructor
+ */
+var Evaluator = function(grammar, transforms, context, relativeContext) {
+	this._grammar = grammar;
+	this._transforms = transforms || {};
+	this._context = context || {};
+	this._relContext = relativeContext || this._context;
+};
+
+/**
+ * Evaluates an expression tree within the configured context.
+ * @param {{}} ast An expression tree object
+ * @returns {Promise<*>} resolves with the resulting value of the expression.
+ */
+Evaluator.prototype.eval = function(ast) {
+	var self = this;
+	return Promise.resolve().then(function() {
+		return handlers[ast.type].call(self, ast);
+	});
+};
+
+/**
+ * Simultaneously evaluates each expression within an array, and delivers the
+ * response as an array with the resulting values at the same indexes as their
+ * originating expressions.
+ * @param {Array<string>} arr An array of expression strings to be evaluated
+ * @returns {Promise<Array<{}>>} resolves with the result array
+ */
+Evaluator.prototype.evalArray = function(arr) {
+	return Promise.all(arr.map(function(elem) {
+		return this.eval(elem);
+	}, this));
+};
+
+/**
+ * Simultaneously evaluates each expression within a map, and delivers the
+ * response as a map with the same keys, but with the evaluated result for each
+ * as their value.
+ * @param {{}} map A map of expression names to expression trees to be
+ *      evaluated
+ * @returns {Promise<{}>} resolves with the result map.
+ */
+Evaluator.prototype.evalMap = function(map) {
+	var keys = Object.keys(map),
+		result = {};
+	var asts = keys.map(function(key) {
+		return this.eval(map[key]);
+	}, this);
+	return Promise.all(asts).then(function(vals) {
+		vals.forEach(function(val, idx) {
+			result[keys[idx]] = val;
+		});
+		return result;
+	});
+};
+
+/**
+ * Applies a filter expression with relative identifier elements to a subject.
+ * The intent is for the subject to be an array of subjects that will be
+ * individually used as the relative context against the provided expression
+ * tree. Only the elements whose expressions result in a truthy value will be
+ * included in the resulting array.
+ *
+ * If the subject is not an array of values, it will be converted to a single-
+ * element array before running the filter.
+ * @param {*} subject The value to be filtered; usually an array. If this value is
+ *      not an array, it will be converted to an array with this value as the
+ *      only element.
+ * @param {{}} expr The expression tree to run against each subject. If the
+ *      tree evaluates to a truthy result, then the value will be included in
+ *      the returned array; otherwise, it will be eliminated.
+ * @returns {Promise<Array>} resolves with an array of values that passed the
+ *      expression filter.
+ * @private
+ */
+Evaluator.prototype._filterRelative = function(subject, expr) {
+	var promises = [];
+	if (!Array.isArray(subject))
+		subject = [subject];
+	subject.forEach(function(elem) {
+		var evalInst = new Evaluator(this._grammar, this._transforms,
+			this._context, elem);
+		promises.push(evalInst.eval(expr));
+	}, this);
+	return Promise.all(promises).then(function(values) {
+		var results = [];
+		values.forEach(function(value, idx) {
+			if (value)
+				results.push(subject[idx]);
+		});
+		return results;
+	});
+};
+
+/**
+ * Applies a static filter expression to a subject value.  If the filter
+ * expression evaluates to boolean true, the subject is returned; if false,
+ * undefined.
+ *
+ * For any other resulting value of the expression, this function will attempt
+ * to respond with the property at that name or index of the subject.
+ * @param {*} subject The value to be filtered.  Usually an Array (for which
+ *      the expression would generally resolve to a numeric index) or an
+ *      Object (for which the expression would generally resolve to a string
+ *      indicating a property name)
+ * @param {{}} expr The expression tree to run against the subject
+ * @returns {Promise<*>} resolves with the value of the drill-down.
+ * @private
+ */
+Evaluator.prototype._filterStatic = function(subject, expr) {
+	return this.eval(expr).then(function(res) {
+		if (typeof res === 'boolean')
+			return res ? subject : undefined;
+		return subject[res];
+	});
+};
+
+module.exports = Evaluator;
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/node_modules/jexl/lib/evaluator/handlers.js
@@ -0,0 +1,159 @@
+/*
+ * Jexl
+ * Copyright (c) 2015 TechnologyAdvice
+ */
+
+/**
+ * Evaluates an ArrayLiteral by returning its value, with each element
+ * independently run through the evaluator.
+ * @param {{type: 'ObjectLiteral', value: <{}>}} ast An expression tree with an
+ *      ObjectLiteral as the top node
+ * @returns {Promise.<[]>} resolves to a map contained evaluated values.
+ * @private
+ */
+exports.ArrayLiteral = function(ast) {
+	return this.evalArray(ast.value);
+};
+
+/**
+ * Evaluates a BinaryExpression node by running the Grammar's evaluator for
+ * the given operator.
+ * @param {{type: 'BinaryExpression', operator: <string>, left: {},
+ *      right: {}}} ast An expression tree with a BinaryExpression as the top
+ *      node
+ * @returns {Promise<*>} resolves with the value of the BinaryExpression.
+ * @private
+ */
+exports.BinaryExpression = function(ast) {
+	var self = this;
+	return Promise.all([
+		this.eval(ast.left),
+		this.eval(ast.right)
+	]).then(function(arr) {
+		return self._grammar[ast.operator].eval(arr[0], arr[1]);
+	});
+};
+
+/**
+ * Evaluates a ConditionalExpression node by first evaluating its test branch,
+ * and resolving with the consequent branch if the test is truthy, or the
+ * alternate branch if it is not. If there is no consequent branch, the test
+ * result will be used instead.
+ * @param {{type: 'ConditionalExpression', test: {}, consequent: {},
+ *      alternate: {}}} ast An expression tree with a ConditionalExpression as
+ *      the top node
+ * @private
+ */
+exports.ConditionalExpression = function(ast) {
+	var self = this;
+	return this.eval(ast.test).then(function(res) {
+		if (res) {
+			if (ast.consequent)
+				return self.eval(ast.consequent);
+			return res;
+		}
+		return self.eval(ast.alternate);
+	});
+};
+
+/**
+ * Evaluates a FilterExpression by applying it to the subject value.
+ * @param {{type: 'FilterExpression', relative: <boolean>, expr: {},
+ *      subject: {}}} ast An expression tree with a FilterExpression as the top
+ *      node
+ * @returns {Promise<*>} resolves with the value of the FilterExpression.
+ * @private
+ */
+exports.FilterExpression = function(ast) {
+	var self = this;
+	return this.eval(ast.subject).then(function(subject) {
+		if (ast.relative)
+			return self._filterRelative(subject, ast.expr);
+		return self._filterStatic(subject, ast.expr);
+	});
+};
+
+/**
+ * Evaluates an Identifier by either stemming from the evaluated 'from'
+ * expression tree or accessing the context provided when this Evaluator was
+ * constructed.
+ * @param {{type: 'Identifier', value: <string>, [from]: {}}} ast An expression
+ *      tree with an Identifier as the top node
+ * @returns {Promise<*>|*} either the identifier's value, or a Promise that
+ *      will resolve with the identifier's value.
+ * @private
+ */
+exports.Identifier = function(ast) {
+	if (ast.from) {
+		return this.eval(ast.from).then(function(context) {
+			if (context === undefined)
+				return undefined;
+			if (Array.isArray(context))
+				context = context[0];
+			return context[ast.value];
+		});
+	}
+	else {
+		return ast.relative ? this._relContext[ast.value] :
+			this._context[ast.value];
+	}
+};
+
+/**
+ * Evaluates a Literal by returning its value property.
+ * @param {{type: 'Literal', value: <string|number|boolean>}} ast An expression
+ *      tree with a Literal as its only node
+ * @returns {string|number|boolean} The value of the Literal node
+ * @private
+ */
+exports.Literal = function(ast) {
+	return ast.value;
+};
+
+/**
+ * Evaluates an ObjectLiteral by returning its value, with each key
+ * independently run through the evaluator.
+ * @param {{type: 'ObjectLiteral', value: <{}>}} ast An expression tree with an
+ *      ObjectLiteral as the top node
+ * @returns {Promise<{}>} resolves to a map contained evaluated values.
+ * @private
+ */
+exports.ObjectLiteral = function(ast) {
+	return this.evalMap(ast.value);
+};
+
+/**
+ * Evaluates a Transform node by applying a function from the transforms map
+ * to the subject value.
+ * @param {{type: 'Transform', name: <string>, subject: {}}} ast An
+ *      expression tree with a Transform as the top node
+ * @returns {Promise<*>|*} the value of the transformation, or a Promise that
+ *      will resolve with the transformed value.
+ * @private
+ */
+exports.Transform = function(ast) {
+	var transform = this._transforms[ast.name];
+	if (!transform)
+		throw new Error("Transform '" + ast.name + "' is not defined.");
+	return Promise.all([
+		this.eval(ast.subject),
+		this.evalArray(ast.args || [])
+	]).then(function(arr) {
+		return transform.apply(null, [arr[0]].concat(arr[1]));
+	});
+};
+
+/**
+ * Evaluates a Unary expression by passing the right side through the
+ * operator's eval function.
+ * @param {{type: 'UnaryExpression', operator: <string>, right: {}}} ast An
+ *      expression tree with a UnaryExpression as the top node
+ * @returns {Promise<*>} resolves with the value of the UnaryExpression.
+ * @constructor
+ */
+exports.UnaryExpression = function(ast) {
+	var self = this;
+	return this.eval(ast.right).then(function(right) {
+		return self._grammar[ast.operator].eval(right);
+	});
+};
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/node_modules/jexl/lib/grammar.js
@@ -0,0 +1,66 @@
+/*
+ * Jexl
+ * Copyright (c) 2015 TechnologyAdvice
+ */
+
+/**
+ * A map of all expression elements to their properties. Note that changes
+ * here may require changes in the Lexer or Parser.
+ * @type {{}}
+ */
+exports.elements = {
+	'.': {type: 'dot'},
+	'[': {type: 'openBracket'},
+	']': {type: 'closeBracket'},
+	'|': {type: 'pipe'},
+	'{': {type: 'openCurl'},
+	'}': {type: 'closeCurl'},
+	':': {type: 'colon'},
+	',': {type: 'comma'},
+	'(': {type: 'openParen'},
+	')': {type: 'closeParen'},
+	'?': {type: 'question'},
+	'+': {type: 'binaryOp', precedence: 30,
+		eval: function(left, right) { return left + right; }},
+	'-': {type: 'binaryOp', precedence: 30,
+		eval: function(left, right) { return left - right; }},
+	'*': {type: 'binaryOp', precedence: 40,
+		eval: function(left, right) { return left * right; }},
+	'/': {type: 'binaryOp', precedence: 40,
+		eval: function(left, right) { return left / right; }},
+	'//': {type: 'binaryOp', precedence: 40,
+		eval: function(left, right) { return Math.floor(left / right); }},
+	'%': {type: 'binaryOp', precedence: 50,
+		eval: function(left, right) { return left % right; }},
+	'^': {type: 'binaryOp', precedence: 50,
+		eval: function(left, right) { return Math.pow(left, right); }},
+	'==': {type: 'binaryOp', precedence: 20,
+		eval: function(left, right) { return left == right; }},
+	'!=': {type: 'binaryOp', precedence: 20,
+		eval: function(left, right) { return left != right; }},
+	'>': {type: 'binaryOp', precedence: 20,
+		eval: function(left, right) { return left > right; }},
+	'>=': {type: 'binaryOp', precedence: 20,
+		eval: function(left, right) { return left >= right; }},
+	'<': {type: 'binaryOp', precedence: 20,
+		eval: function(left, right) { return left < right; }},
+	'<=': {type: 'binaryOp', precedence: 20,
+		eval: function(left, right) { return left <= right; }},
+	'&&': {type: 'binaryOp', precedence: 10,
+		eval: function(left, right) { return left && right; }},
+	'||': {type: 'binaryOp', precedence: 10,
+		eval: function(left, right) { return left || right; }},
+	'in': {type: 'binaryOp', precedence: 20,
+		eval: function(left, right) {
+			if (typeof right === 'string')
+				return right.indexOf(left) !== -1;
+			if (Array.isArray(right)) {
+				return right.some(function(elem) {
+					return elem == left;
+				});
+			}
+			return false;
+		}},
+	'!': {type: 'unaryOp', precedence: Infinity,
+		eval: function(right) { return !right; }}
+};
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/node_modules/jexl/lib/parser/Parser.js
@@ -0,0 +1,188 @@
+/*
+ * Jexl
+ * Copyright (c) 2015 TechnologyAdvice
+ */
+
+var handlers = require('./handlers'),
+	states = require('./states').states;
+
+/**
+ * The Parser is a state machine that converts tokens from the {@link Lexer}
+ * into an Abstract Syntax Tree (AST), capable of being evaluated in any
+ * context by the {@link Evaluator}.  The Parser expects that all tokens
+ * provided to it are legal and typed properly according to the grammar, but
+ * accepts that the tokens may still be in an invalid order or in some other
+ * unparsable configuration that requires it to throw an Error.
+ * @param {{}} grammar The grammar map to use to parse Jexl strings
+ * @param {string} [prefix] A string prefix to prepend to the expression string
+ *      for error messaging purposes.  This is useful for when a new Parser is
+ *      instantiated to parse an subexpression, as the parent Parser's
+ *      expression string thus far can be passed for a more user-friendly
+ *      error message.
+ * @param {{}} [stopMap] A mapping of token types to any truthy value. When the
+ *      token type is encountered, the parser will return the mapped value
+ *      instead of boolean false.
+ * @constructor
+ */
+function Parser(grammar, prefix, stopMap) {
+	this._grammar = grammar;
+	this._state = 'expectOperand';
+	this._tree = null;
+	this._exprStr = prefix || '';
+	this._relative = false;
+	this._stopMap = stopMap || {};
+}
+
+/**
+ * Processes a new token into the AST and manages the transitions of the state
+ * machine.
+ * @param {{type: <string>}} token A token object, as provided by the
+ *      {@link Lexer#tokenize} function.
+ * @throws {Error} if a token is added when the Parser has been marked as
+ *      complete by {@link #complete}, or if an unexpected token type is added.
+ * @returns {boolean|*} the stopState value if this parser encountered a token
+ *      in the stopState mapb; false if tokens can continue.
+ */
+Parser.prototype.addToken = function(token) {
+	if (this._state == 'complete')
+		throw new Error('Cannot add a new token to a completed Parser');
+	var state = states[this._state],
+		startExpr = this._exprStr;
+	this._exprStr += token.raw;
+	if (state.subHandler) {
+		if (!this._subParser)
+			this._startSubExpression(startExpr);
+		var stopState = this._subParser.addToken(token);
+		if (stopState) {
+			this._endSubExpression();
+			if (this._parentStop)
+				return stopState;
+			this._state = stopState;
+		}
+	}
+	else if (state.tokenTypes[token.type]) {
+		var typeOpts = state.tokenTypes[token.type],
+			handleFunc = handlers[token.type];
+		if (typeOpts.handler)
+			handleFunc = typeOpts.handler;
+		if (handleFunc)
+			handleFunc.call(this, token);
+		if (typeOpts.toState)
+			this._state = typeOpts.toState;
+	}
+	else if (this._stopMap[token.type])
+		return this._stopMap[token.type];
+	else {
+		throw new Error('Token ' + token.raw + ' (' + token.type +
+			') unexpected in expression: ' + this._exprStr);
+	}
+	return false;
+};
+
+/**
+ * Processes an array of tokens iteratively through the {@link #addToken}
+ * function.
+ * @param {Array<{type: <string>}>} tokens An array of tokens, as provided by
+ *      the {@link Lexer#tokenize} function.
+ */
+Parser.prototype.addTokens = function(tokens) {
+	tokens.forEach(this.addToken, this);
+};
+
+/**
+ * Marks this Parser instance as completed and retrieves the full AST.
+ * @returns {{}|null} a full expression tree, ready for evaluation by the
+ *      {@link Evaluator#eval} function, or null if no tokens were passed to
+ *      the parser before complete was called
+ * @throws {Error} if the parser is not in a state where it's legal to end
+ *      the expression, indicating that the expression is incomplete
+ */
+Parser.prototype.complete = function() {
+	if (this._cursor && !states[this._state].completable)
+		throw new Error('Unexpected end of expression: ' + this._exprStr);
+	if (this._subParser)
+		this._endSubExpression();
+	this._state = 'complete';
+	return this._cursor ? this._tree : null;
+};
+
+/**
+ * Indicates whether the expression tree contains a relative path identifier.
+ * @returns {boolean} true if a relative identifier exists; false otherwise.
+ */
+Parser.prototype.isRelative = function() {
+	return this._relative;
+};
+
+/**
+ * Ends a subexpression by completing the subParser and passing its result
+ * to the subHandler configured in the current state.
+ * @private
+ */
+Parser.prototype._endSubExpression = function() {
+	states[this._state].subHandler.call(this, this._subParser.complete());
+	this._subParser = null;
+};
+
+/**
+ * Places a new tree node at the current position of the cursor (to the 'right'
+ * property) and then advances the cursor to the new node. This function also
+ * handles setting the parent of the new node.
+ * @param {{type: <string>}} node A node to be added to the AST
+ * @private
+ */
+Parser.prototype._placeAtCursor = function(node) {
+	if (!this._cursor)
+		this._tree = node;
+	else {
+		this._cursor.right = node;
+		this._setParent(node, this._cursor);
+	}
+	this._cursor = node;
+};
+
+/**
+ * Places a tree node before the current position of the cursor, replacing
+ * the node that the cursor currently points to. This should only be called in
+ * cases where the cursor is known to exist, and the provided node already
+ * contains a pointer to what's at the cursor currently.
+ * @param {{type: <string>}} node A node to be added to the AST
+ * @private
+ */
+Parser.prototype._placeBeforeCursor = function(node) {
+	this._cursor = this._cursor._parent;
+	this._placeAtCursor(node);
+};
+
+/**
+ * Sets the parent of a node by creating a non-enumerable _parent property
+ * that points to the supplied parent argument.
+ * @param {{type: <string>}} node A node of the AST on which to set a new
+ *      parent
+ * @param {{type: <string>}} parent An existing node of the AST to serve as the
+ *      parent of the new node
+ * @private
+ */
+Parser.prototype._setParent = function(node, parent) {
+	Object.defineProperty(node, '_parent', {
+		value: parent,
+		writable: true
+	});
+};
+
+/**
+ * Prepares the Parser to accept a subexpression by (re)instantiating the
+ * subParser.
+ * @param {string} [exprStr] The expression string to prefix to the new Parser
+ * @private
+ */
+Parser.prototype._startSubExpression = function(exprStr) {
+	var endStates = states[this._state].endStates;
+	if (!endStates) {
+		this._parentStop = true;
+		endStates = this._stopMap;
+	}
+	this._subParser = new Parser(this._grammar, exprStr, endStates);
+};
+
+module.exports = Parser;
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/node_modules/jexl/lib/parser/handlers.js
@@ -0,0 +1,210 @@
+/*
+ * Jexl
+ * Copyright (c) 2015 TechnologyAdvice
+ */
+
+/**
+ * Handles a subexpression that's used to define a transform argument's value.
+ * @param {{type: <string>}} ast The subexpression tree
+ */
+exports.argVal = function(ast) {
+	this._cursor.args.push(ast);
+};
+
+/**
+ * Handles new array literals by adding them as a new node in the AST,
+ * initialized with an empty array.
+ */
+exports.arrayStart = function() {
+	this._placeAtCursor({
+		type: 'ArrayLiteral',
+		value: []
+	});
+};
+
+/**
+ * Handles a subexpression representing an element of an array literal.
+ * @param {{type: <string>}} ast The subexpression tree
+ */
+exports.arrayVal = function(ast) {
+	if (ast)
+		this._cursor.value.push(ast);
+};
+
+/**
+ * Handles tokens of type 'binaryOp', indicating an operation that has two
+ * inputs: a left side and a right side.
+ * @param {{type: <string>}} token A token object
+ */
+exports.binaryOp = function(token) {
+	var precedence = this._grammar[token.value].precedence || 0,
+		parent = this._cursor._parent;
+	while (parent && parent.operator &&
+			this._grammar[parent.operator].precedence >= precedence) {
+		this._cursor = parent;
+		parent = parent._parent;
+	}
+	var node = {
+		type: 'BinaryExpression',
+		operator: token.value,
+		left: this._cursor
+	};
+	this._setParent(this._cursor, node);
+	this._cursor = parent;
+	this._placeAtCursor(node);
+};
+
+/**
+ * Handles successive nodes in an identifier chain.  More specifically, it
+ * sets values that determine how the following identifier gets placed in the
+ * AST.
+ */
+exports.dot = function() {
+	this._nextIdentEncapsulate = this._cursor &&
+		(this._cursor.type != 'BinaryExpression' ||
+		(this._cursor.type == 'BinaryExpression' && this._cursor.right)) &&
+		this._cursor.type != 'UnaryExpression';
+	this._nextIdentRelative = !this._cursor ||
+		(this._cursor && !this._nextIdentEncapsulate);
+	if (this._nextIdentRelative)
+		this._relative = true;
+};
+
+/**
+ * Handles a subexpression used for filtering an array returned by an
+ * identifier chain.
+ * @param {{type: <string>}} ast The subexpression tree
+ */
+exports.filter = function(ast) {
+	this._placeBeforeCursor({
+		type: 'FilterExpression',
+		expr: ast,
+		relative: this._subParser.isRelative(),
+		subject: this._cursor
+	});
+};
+
+/**
+ * Handles identifier tokens by adding them as a new node in the AST.
+ * @param {{type: <string>}} token A token object
+ */
+exports.identifier = function(token) {
+	var node = {
+		type: 'Identifier',
+		value: token.value
+	};
+	if (this._nextIdentEncapsulate) {
+		node.from = this._cursor;
+		this._placeBeforeCursor(node);
+		this._nextIdentEncapsulate = false;
+	}
+	else {
+		if (this._nextIdentRelative)
+			node.relative = true;
+		this._placeAtCursor(node);
+	}
+};
+
+/**
+ * Handles literal values, such as strings, booleans, and numerics, by adding
+ * them as a new node in the AST.
+ * @param {{type: <string>}} token A token object
+ */
+exports.literal = function(token) {
+	this._placeAtCursor({
+		type: 'Literal',
+		value: token.value
+	});
+};
+
+/**
+ * Queues a new object literal key to be written once a value is collected.
+ * @param {{type: <string>}} token A token object
+ */
+exports.objKey = function(token) {
+	this._curObjKey = token.value;
+};
+
+/**
+ * Handles new object literals by adding them as a new node in the AST,
+ * initialized with an empty object.
+ */
+exports.objStart = function() {
+	this._placeAtCursor({
+		type: 'ObjectLiteral',
+		value: {}
+	});
+};
+
+/**
+ * Handles an object value by adding its AST to the queued key on the object
+ * literal node currently at the cursor.
+ * @param {{type: <string>}} ast The subexpression tree
+ */
+exports.objVal = function(ast) {
+	this._cursor.value[this._curObjKey] = ast;
+};
+
+/**
+ * Handles traditional subexpressions, delineated with the groupStart and
+ * groupEnd elements.
+ * @param {{type: <string>}} ast The subexpression tree
+ */
+exports.subExpression = function(ast) {
+	this._placeAtCursor(ast);
+};
+
+/**
+ * Handles a completed alternate subexpression of a ternary operator.
+ * @param {{type: <string>}} ast The subexpression tree
+ */
+exports.ternaryEnd = function(ast) {
+	this._cursor.alternate = ast;
+};
+
+/**
+ * Handles a completed consequent subexpression of a ternary operator.
+ * @param {{type: <string>}} ast The subexpression tree
+ */
+exports.ternaryMid = function(ast) {
+	this._cursor.consequent = ast;
+};
+
+/**
+ * Handles the start of a new ternary expression by encapsulating the entire
+ * AST in a ConditionalExpression node, and using the existing tree as the
+ * test element.
+ */
+exports.ternaryStart = function() {
+	this._tree = {
+		type: 'ConditionalExpression',
+		test: this._tree
+	};
+	this._cursor = this._tree;
+};
+
+/**
+ * Handles identifier tokens when used to indicate the name of a transform to
+ * be applied.
+ * @param {{type: <string>}} token A token object
+ */
+exports.transform = function(token) {
+	this._placeBeforeCursor({
+		type: 'Transform',
+		name: token.value,
+		args: [],
+		subject: this._cursor
+	});
+};
+
+/**
+ * Handles token of type 'unaryOp', indicating that the operation has only
+ * one input: a right side.
+ * @param {{type: <string>}} token A token object
+ */
+exports.unaryOp = function(token) {
+	this._placeAtCursor({
+		type: 'UnaryExpression',
+		operator: token.value
+	});
+};
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/node_modules/jexl/lib/parser/states.js
@@ -0,0 +1,154 @@
+/*
+ * Jexl
+ * Copyright (c) 2015 TechnologyAdvice
+ */
+
+var h = require('./handlers');
+
+/**
+ * A mapping of all states in the finite state machine to a set of instructions
+ * for handling or transitioning into other states. Each state can be handled
+ * in one of two schemes: a tokenType map, or a subHandler.
+ *
+ * Standard expression elements are handled through the tokenType object. This
+ * is an object map of all legal token types to encounter in this state (and
+ * any unexpected token types will generate a thrown error) to an options
+ * object that defines how they're handled.  The available options are:
+ *
+ *      {string} toState: The name of the state to which to transition
+ *          immediately after handling this token
+ *      {string} handler: The handler function to call when this token type is
+ *          encountered in this state.  If omitted, the default handler
+ *          matching the token's "type" property will be called. If the handler
+ *          function does not exist, no call will be made and no error will be
+ *          generated.  This is useful for tokens whose sole purpose is to
+ *          transition to other states.
+ *
+ * States that consume a subexpression should define a subHandler, the
+ * function to be called with an expression tree argument when the
+ * subexpression is complete. Completeness is determined through the
+ * endStates object, which maps tokens on which an expression should end to the
+ * state to which to transition once the subHandler function has been called.
+ *
+ * Additionally, any state in which it is legal to mark the AST as completed
+ * should have a 'completable' property set to boolean true.  Attempting to
+ * call {@link Parser#complete} in any state without this property will result
+ * in a thrown Error.
+ *
+ * @type {{}}
+ */
+exports.states = {
+	expectOperand: {
+		tokenTypes: {
+			literal: {toState: 'expectBinOp'},
+			identifier: {toState: 'identifier'},
+			unaryOp: {},
+			openParen: {toState: 'subExpression'},
+			openCurl: {toState: 'expectObjKey', handler: h.objStart},
+			dot: {toState: 'traverse'},
+			openBracket: {toState: 'arrayVal', handler: h.arrayStart}
+		}
+	},
+	expectBinOp: {
+		tokenTypes: {
+			binaryOp: {toState: 'expectOperand'},
+			pipe: {toState: 'expectTransform'},
+			dot: {toState: 'traverse'},
+			question: {toState: 'ternaryMid', handler: h.ternaryStart}
+		},
+		completable: true
+	},
+	expectTransform: {
+		tokenTypes: {
+			identifier: {toState: 'postTransform', handler: h.transform}
+		}
+	},
+	expectObjKey: {
+		tokenTypes: {
+			identifier: {toState: 'expectKeyValSep', handler: h.objKey},
+			closeCurl: {toState: 'expectBinOp'}
+		}
+	},
+	expectKeyValSep: {
+		tokenTypes: {
+			colon: {toState: 'objVal'}
+		}
+	},
+	postTransform: {
+		tokenTypes: {
+			openParen: {toState: 'argVal'},
+			binaryOp: {toState: 'expectOperand'},
+			dot: {toState: 'traverse'},
+			openBracket: {toState: 'filter'},
+			pipe: {toState: 'expectTransform'}
+		},
+		completable: true
+	},
+	postTransformArgs: {
+		tokenTypes: {
+			binaryOp: {toState: 'expectOperand'},
+			dot: {toState: 'traverse'},
+			openBracket: {toState: 'filter'},
+			pipe: {toState: 'expectTransform'}
+		},
+		completable: true
+	},
+	identifier: {
+		tokenTypes: {
+			binaryOp: {toState: 'expectOperand'},
+			dot: {toState: 'traverse'},
+			openBracket: {toState: 'filter'},
+			pipe: {toState: 'expectTransform'},
+			question: {toState: 'ternaryMid', handler: h.ternaryStart}
+		},
+		completable: true
+	},
+	traverse: {
+		tokenTypes: {
+			'identifier': {toState: 'identifier'}
+		}
+	},
+	filter: {
+		subHandler: h.filter,
+		endStates: {
+			closeBracket: 'identifier'
+		}
+	},
+	subExpression: {
+		subHandler: h.subExpression,
+		endStates: {
+			closeParen: 'expectBinOp'
+		}
+	},
+	argVal: {
+		subHandler: h.argVal,
+		endStates: {
+			comma: 'argVal',
+			closeParen: 'postTransformArgs'
+		}
+	},
+	objVal: {
+		subHandler: h.objVal,
+		endStates: {
+			comma: 'expectObjKey',
+			closeCurl: 'expectBinOp'
+		}
+	},
+	arrayVal: {
+		subHandler: h.arrayVal,
+		endStates: {
+			comma: 'arrayVal',
+			closeBracket: 'expectBinOp'
+		}
+	},
+	ternaryMid: {
+		subHandler: h.ternaryMid,
+		endStates: {
+			colon: 'ternaryEnd'
+		}
+	},
+	ternaryEnd: {
+		subHandler: h.ternaryEnd,
+		completable: true
+	}
+};
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/node_modules/jexl/package.json
@@ -0,0 +1,111 @@
+{
+  "_args": [
+    [
+      {
+        "raw": "jexl@1.1.4",
+        "scope": null,
+        "escapedName": "jexl",
+        "name": "jexl",
+        "rawSpec": "1.1.4",
+        "spec": "1.1.4",
+        "type": "version"
+      },
+      "/home/mythmon/src/normandy/normandy-addon"
+    ]
+  ],
+  "_from": "jexl@1.1.4",
+  "_id": "jexl@1.1.4",
+  "_inCache": true,
+  "_location": "/jexl",
+  "_nodeVersion": "4.4.4",
+  "_npmOperationalInternal": {
+    "host": "packages-12-west.internal.npmjs.com",
+    "tmp": "tmp/jexl-1.1.4.tgz_1462568392681_0.36264530336484313"
+  },
+  "_npmUser": {
+    "name": "tomfrost",
+    "email": "tom@frosteddesign.com"
+  },
+  "_npmVersion": "2.15.5",
+  "_phantomChildren": {},
+  "_requested": {
+    "raw": "jexl@1.1.4",
+    "scope": null,
+    "escapedName": "jexl",
+    "name": "jexl",
+    "rawSpec": "1.1.4",
+    "spec": "1.1.4",
+    "type": "version"
+  },
+  "_requiredBy": [
+    "/"
+  ],
+  "_resolved": "https://registry.npmjs.org/jexl/-/jexl-1.1.4.tgz",
+  "_shasum": "35cf86f881ea219d1e215c7a41e2e747ce6ee8a6",
+  "_shrinkwrap": null,
+  "_spec": "jexl@1.1.4",
+  "_where": "/home/mythmon/src/normandy/normandy-addon",
+  "author": {
+    "name": "Tom Shawver",
+    "email": "tom@frosteddesign.com"
+  },
+  "bugs": {
+    "url": "https://github.com/TechnologyAdvice/jexl/issues"
+  },
+  "dependencies": {},
+  "description": "Javascript Expression Language: Powerful context-based expression parser and evaluator",
+  "devDependencies": {
+    "browserify": "=9.0.3",
+    "chai": "^2.0.0",
+    "chai-as-promised": "^4.2.0",
+    "gulp": "=3.8.11",
+    "gulp-istanbul": "^0.6.0",
+    "gulp-istanbul-enforcer": "^1.0.3",
+    "gulp-mocha": "^2.0.0",
+    "gulp-rename": "=1.2.0",
+    "gulp-uglify": "=1.1.0",
+    "istanbul": "^0.3.8",
+    "mocha": "^2.1.0",
+    "vinyl-transform": "=1.0.0"
+  },
+  "directories": {
+    "test": "test"
+  },
+  "dist": {
+    "shasum": "35cf86f881ea219d1e215c7a41e2e747ce6ee8a6",
+    "tarball": "https://registry.npmjs.org/jexl/-/jexl-1.1.4.tgz"
+  },
+  "gitHead": "0cc2a9107a276905a1d02cf9fbe94531596fffa3",
+  "homepage": "https://github.com/TechnologyAdvice/jexl",
+  "keywords": [
+    "JSON",
+    "expression",
+    "evaluator",
+    "parser",
+    "target",
+    "context",
+    "jsep",
+    "filter",
+    "selector"
+  ],
+  "license": "MIT",
+  "main": "lib/Jexl.js",
+  "maintainers": [
+    {
+      "name": "tomfrost",
+      "email": "tom@frosteddesign.com"
+    }
+  ],
+  "name": "jexl",
+  "optionalDependencies": {},
+  "readme": "ERROR: No README data found!",
+  "repository": {
+    "type": "git",
+    "url": "git+https://github.com/TechnologyAdvice/jexl.git"
+  },
+  "scripts": {
+    "prepublish": "gulp",
+    "test": "istanbul cover _mocha -- -R spec --recursive test"
+  },
+  "version": "1.1.4"
+}
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/package.json
@@ -0,0 +1,76 @@
+{
+  "title": "Shield Recipe Client",
+  "name": "shield-recipe-client",
+  "id": "shield-recipe-client@mozilla.org",
+  "version": "0.1.3",
+  "description": "Client to download and run recipes for SHIELD, Heartbeat, etc.",
+  "main": "index.js",
+  "author": "Mike Cooper <mcooper@mozilla.com>",
+  "repository": "https://github.com/mozilla/normandy-addon",
+  "scripts": {
+    "test": "jpm test",
+    "build": "jpm xpi"
+  },
+  "engines": {
+    "firefox": ">=51.0a1"
+  },
+  "license": "MPL-2.0",
+  "keywords": [
+    "jetpack"
+  ],
+  "preferences": [
+    {
+      "name": "api_url",
+      "title": "API URL",
+      "description": "URL prefix to use for server requests, including API version",
+      "type": "string",
+      "value": "https://self-repair.mozilla.org/api/v1"
+    },
+    {
+      "name": "enabled",
+      "title": "Enabled",
+      "type": "bool",
+      "value": true
+    },
+    {
+      "name": "log_level",
+      "title": "Log Level",
+      "type": "string",
+      "value": "warning"
+    },
+    {
+      "name": "dev_mode",
+      "title": "Developer Mode",
+      "type": "bool",
+      "value": false
+    },
+    {
+      "name": "startup_delay",
+      "title": "Startup Delay",
+      "description": "Number of seconds to wait after startup before running recipes",
+      "type": "integer",
+      "value": 300
+    },
+    {
+      "name": "input_host",
+      "title": "Input Host",
+      "type": "string"
+    }
+  ],
+  "devDependencies": {
+    "babel-eslint": "6.1.2",
+    "eslint": "^3.8.1",
+    "eslint-config-normandy": "1.0.0",
+    "eslint-plugin-babel": "3.3.0",
+    "eslint-plugin-mozilla": "^0.2.3",
+    "jpm": "1.2.2",
+    "woodchipper": "0.9.1"
+  },
+  "dependencies": {
+    "jexl": "1.1.4",
+    "sha.js": "2.4.5"
+  },
+  "permissions": {
+    "multiprocess": true
+  }
+}
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/test/.eslintrc.js
@@ -0,0 +1,8 @@
+"use strict";
+
+module.exports = {
+  globals: {
+    console: false,
+    module: false,
+  },
+};
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/test/TestUtils.jsm
@@ -0,0 +1,21 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * 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/. */
+
+"use strict";
+
+/* eslint-disable no-console */
+this.EXPORTED_SYMBOLS = ["TestUtils"];
+
+this.TestUtils = {
+  promiseTest(test) {
+    return function(assert, done) {
+      test(assert)
+      .catch(err => {
+        console.error(err);
+        assert.ok(false, err);
+      })
+      .then(() => done());
+    };
+  },
+};
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/test/jetpack-package.ini
@@ -0,0 +1,5 @@
+[test-driver.js]
+[test-env-expressions.js]
+[test-EventEmitter.js]
+[test-Heartbeat.js]
+[test-storage.js]
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/test/test-EventEmitter.js
@@ -0,0 +1,138 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * 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/. */
+
+"use strict";
+
+/* global exports:true */
+const {Cu} = require("chrome");
+const testRunner = require("sdk/test");
+const {before, after} = require("sdk/test/utils");
+
+Cu.import("resource://shield-recipe-client/lib/NormandyDriver.jsm");
+Cu.import("resource://shield-recipe-client/lib/SandboxManager.jsm");
+
+let sandboxManager;
+let eventEmitter;
+
+exports["test it works"] = (assert, done) => {
+  eventEmitter.on("foo", () => done());
+  eventEmitter.emit("foo");
+};
+
+exports["test it fires events async"] = (assert, done) => {
+  let flag = 0;
+  eventEmitter.on("foo", () => {
+    flag += 1;
+    assert.equal(flag, 1, "event handler fired multiple times");
+    done();
+  });
+  assert.equal(flag, 0, "event handler fired before event");
+  eventEmitter.emit("foo");
+  assert.equal(flag, 0, "event handler fired syncronously");
+};
+
+exports["test it can safely fire events with no listeners"] = () => {
+  eventEmitter.emit("foo");
+};
+
+exports["test it passes arguments"] = (assert, done) => {
+  eventEmitter.on("foo", arg => {
+    assert.equal(arg, "it works", "argument was not passed to event handler");
+    done();
+  });
+  eventEmitter.emit("foo", "it works");
+};
+
+exports["test it works with multiple listeners in order"] = (assert, done) => {
+  let counter = 0;
+
+  eventEmitter.on("foo", () => {
+    counter += 1;
+    assert.equal(counter, 1, "counter was not expected value");
+  });
+
+  eventEmitter.on("foo", () => {
+    counter += 10;
+    assert.equal(counter, 11, "counter was not expected value");
+    done();
+  });
+
+  eventEmitter.emit("foo");
+};
+
+exports["test off() works"] = (assert, done) => {
+
+  let count = 0;
+  function cb1() {
+    count += 1;
+  }
+  function cb2() {
+    count += 10;
+    eventEmitter.off("foo", cb2);
+  }
+  function allDone() {
+    assert.equal(count, 12, "counter was not expected value");
+    done();
+  }
+
+  eventEmitter.on("foo", cb1);
+  eventEmitter.on("foo", cb2);
+  eventEmitter.on("done", allDone);
+
+  eventEmitter.emit("foo");
+  eventEmitter.emit("foo");
+  eventEmitter.emit("done");
+};
+
+exports["test once() works"] = (assert, done) => {
+  let count = 0;
+  function cb() {
+    count += 1;
+  }
+  function allDone() {
+    assert.equal(count, 1, "counter was not expected value");
+    done();
+  }
+
+  eventEmitter.on("done", allDone);
+  eventEmitter.once("foo", cb);
+
+  eventEmitter.emit("foo");
+  eventEmitter.emit("foo");
+  eventEmitter.emit("done");
+};
+
+// Because of the way the event emitter iterates, this is a fragile case
+exports["test off() during event handler works"] = (assert, done) => {
+  let count = 0;
+  function cb() {
+    count += 1;
+  }
+  function allDone() {
+    assert.equal(count, 2, "counter was not expected value");
+    done();
+  }
+
+  eventEmitter.once("foo", cb);
+  eventEmitter.on("foo", cb);
+  eventEmitter.on("done", allDone);
+
+  eventEmitter.emit("foo");
+  eventEmitter.emit("done");
+};
+
+before(exports, () => {
+  sandboxManager = new SandboxManager();
+  sandboxManager.addHold("test running");
+  let driver = new NormandyDriver(sandboxManager);
+  let sandboxedDriver = Cu.cloneInto(driver, sandboxManager.sandbox, {cloneFunctions: true});
+  eventEmitter = new sandboxManager.sandbox.EventEmitter(sandboxedDriver).wrappedJSObject;
+});
+
+after(exports, (testName, assert, done) => {
+  sandboxManager.removeHold("test running");
+  sandboxManager.assertNuked(assert, done);
+});
+
+testRunner.run(exports);
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/test/test-Heartbeat.js
@@ -0,0 +1,229 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * 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/. */
+
+"use strict";
+
+/* global exports:true, require:false */
+const {Cu} = require("chrome");
+const tabs = require("sdk/tabs");
+const {browserWindows} = require("sdk/windows");
+const testRunner = require("sdk/test");
+const {before, after} = require("sdk/test/utils");
+
+Cu.import("resource://gre/modules/Services.jsm");
+
+Cu.import("resource://shield-recipe-client/lib/Heartbeat.jsm");
+Cu.import("resource://shield-recipe-client/lib/SandboxManager.jsm");
+Cu.import("resource://shield-recipe-client/lib/NormandyDriver.jsm");
+
+let sandboxManager;
+let targetWindow;
+let notificationBox;
+
+let eventEmitter;
+
+exports["test it shows a heartbeat panel"] = assert => {
+  let preCount = notificationBox.childElementCount;
+  new Heartbeat(targetWindow, eventEmitter, sandboxManager, {
+    testing: true,
+    flowId: "test",
+    message: "test",
+  });
+  assert.equal(notificationBox.childElementCount, preCount + 1, "wrong number of notifications open");
+};
+
+exports["test it shows five stars when there is no engagementButtonLabel"] = assert => {
+  let hb = new Heartbeat(targetWindow, eventEmitter, sandboxManager, {
+    testing: true,
+    flowId: "test",
+    message: "test",
+    engagementButtonLabel: undefined,
+  });
+  assert.equal(hb.notice.querySelectorAll(".star-x").length, 5, "wrong number of stars");
+  assert.equal(hb.notice.querySelectorAll(".notification-button").length, 0, "engagement button shown when not expected");
+};
+
+exports["test it shows a button when there is an engagementButtonLabel"] = assert => {
+  let hb = new Heartbeat(targetWindow, eventEmitter, sandboxManager, {
+    testing: true,
+    flowId: "test",
+    message: "test",
+    engagementButtonLabel: "Click me!",
+  });
+  assert.equal(hb.notice.querySelectorAll(".star-x").length, 0, "stars shown when unexpected");
+  const engagementButton = hb.notice.querySelector(".notification-button");
+  assert.ok(engagementButton, "engagement button was not added");
+  assert.equal(engagementButton.label, "Click me!");
+};
+
+exports["test it shows a learn more link"] = assert => {
+  let hb = new Heartbeat(targetWindow, eventEmitter, sandboxManager, {
+    testing: true,
+    flowId: "test",
+    message: "test",
+    learnMoreMessage: "Learn More",
+    learnMoreUrl: "https://example.org/learnmore",
+  });
+  let learnMoreEl = hb.notice.querySelector(".text-link");
+  assert.equal(learnMoreEl.href, "https://example.org/learnmore", "learn more url wrong");
+  assert.equal(learnMoreEl.value, "Learn More", "learn more label wrong");
+};
+
+exports["test it shows the message"] = assert => {
+  let hb = new Heartbeat(targetWindow, eventEmitter, sandboxManager, {
+    testing: true,
+    flowId: "test",
+    message: "test",
+  });
+  let messageEl = targetWindow.document.getAnonymousElementByAttribute(
+    hb.notice,
+    "anonid",
+    "messageText"
+  );
+  assert.equal(messageEl.textContent, "test", "heartbeat prompt showed wrong message");
+};
+
+exports["test it pings telemetry"] = (assert, done) => {
+  let hb = new Heartbeat(targetWindow, eventEmitter, sandboxManager, {
+    testing: true,
+    flowId: "test",
+    message: "test",
+  });
+
+  hb.eventEmitter.on("TelemetrySent", payload => {
+    assertOrdered(assert, [0, payload.offeredTS, payload.closedTS, Date.now()]);
+    done();
+  });
+
+  // triggers sending ping to normandy
+  closeAllNotifications();
+};
+
+exports["test it includes learnMoreTS in payload if learn more is clicked"] = (assert, done) => {
+  let hb = new Heartbeat(targetWindow, eventEmitter, sandboxManager, {
+    testing: true,
+    flowId: "test",
+    message: "test",
+    learnMoreMessage: "Learn More",
+    learnMoreUrl: "https://example.org/learnmore",
+  });
+
+  hb.eventEmitter.on("TelemetrySent", payload => {
+    assertOrdered(assert, [0, payload.offeredTS, payload.learnMoreTS, payload.closedTS, Date.now()]);
+    // Close learn more tab
+    tabs[tabs.length - 1].close();
+    done();
+  });
+
+  let learnMoreEl = hb.notice.querySelector(".text-link");
+  learnMoreEl.click();
+
+  // triggers sending ping to normandy
+  closeAllNotifications();
+};
+
+exports["test it opens an engagement page after interaction"] = (assert, done) => {
+  let hb = new Heartbeat(targetWindow, eventEmitter, sandboxManager, {
+    testing: true,
+    flowId: "test",
+    message: "test",
+    engagementButtonLabel: "Engage whooo",
+    postAnswerUrl: "about:about",
+  });
+
+  tabs.on("ready", () => {
+    assert.equal(tabs.activeTab.url, "about:about", "wrong url loaded in learn more tab");
+    // Close engagement tab
+    tabs[tabs.length - 1].close();
+    done();
+  });
+
+  let engagementEl = hb.notice.querySelector(".notification-button");
+  engagementEl.click();
+};
+
+exports["test it sends telemetry when the window is closed"] = (assert, done) => {
+  browserWindows.open({
+    url: "about:blank",
+    onOpen: () => {
+      targetWindow = Services.wm.getMostRecentWindow("navigator:browser");
+      let hb = new Heartbeat(targetWindow, eventEmitter, sandboxManager, {
+        testing: true,
+        flowId: "test",
+        message: "test",
+      });
+
+      hb.eventEmitter.on("TelemetrySent", payload => {
+        assertOrdered(assert, [0, payload.offeredTS, payload.windowClosedTS, Date.now()]);
+        done();
+      });
+
+      // triggers sending ping to normandy
+      targetWindow.close();
+    },
+  });
+};
+
+function closeAllNotifications() {
+  if (notificationBox.allNotifications.length === 0) {
+    return Promise.resolve();
+  }
+
+  let promises = [];
+
+  for (let notification of notificationBox.allNotifications) {
+    promises.push(waitForNotificationClose(notification));
+    notification.close();
+  }
+
+  return Promise.all(promises);
+}
+
+function waitForNotificationClose(notification) {
+  return new Promise(resolve => {
+    let parent = notification.parentNode;
+
+    let observer = new targetWindow.MutationObserver(mutations => {
+      for (let mutation of mutations) {
+        for (let i = 0; i < mutation.removedNodes.length; i++) {
+          if (mutation.removedNodes.item(i) === notification) {
+            observer.disconnect();
+            resolve();
+          }
+        }
+      }
+    });
+    observer.observe(parent, {childList: true});
+  });
+}
+
+/**
+ * Check if an array is in non-descending order
+ */
+function assertOrdered(assert, arr) {
+  for (let i = 0; i < arr.length - 1; i++) {
+    assert.ok(arr[i] <= arr[i + 1],
+      `element ${i} (${arr[i]}) is not less than or equal to element ${i + 1} (${arr[i + 1]})`);
+  }
+}
+
+before(exports, () => {
+  targetWindow = Services.wm.getMostRecentWindow("navigator:browser");
+  notificationBox = targetWindow.document.querySelector("#high-priority-global-notificationbox");
+  sandboxManager = new SandboxManager();
+  let driver = new NormandyDriver(sandboxManager);
+  sandboxManager.addHold("test running");
+  let sandboxedDriver = Cu.cloneInto(driver, sandboxManager.sandbox, {cloneFunctions: true});
+  eventEmitter = new sandboxManager.sandbox.EventEmitter(sandboxedDriver).wrappedJSObject;
+});
+
+after(exports, (testName, assert, done) => {
+  closeAllNotifications()
+  .then(() => {
+    sandboxManager.removeHold("test running");
+    sandboxManager.assertNuked(assert, done);
+  });
+});
+
+testRunner.run(exports);
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/test/test-driver.js
@@ -0,0 +1,40 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * 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/. */
+
+"use strict";
+
+const {Cu} = require("chrome");
+const testRunner = require("sdk/test");
+const {before, after} = require("sdk/test/utils");
+
+Cu.import("resource://shield-recipe-client/lib/NormandyDriver.jsm");
+Cu.import("resource://shield-recipe-client/lib/SandboxManager.jsm");
+
+let sandboxManager;
+let driver;
+
+exports["test uuid format"] = assert => {
+  let uuid = driver.uuid();
+  assert.ok(/^[a-f0-9-]{36}$/.test(uuid), "invalid uuid format");
+};
+
+exports["test uuid is unique"] = assert => {
+  let uuid1 = driver.uuid();
+  let uuid2 = driver.uuid();
+  assert.notEqual(uuid1, uuid2, "uuids are unique");
+};
+
+before(exports, () => {
+  sandboxManager = new SandboxManager();
+  sandboxManager.addHold("test running");
+  driver = new NormandyDriver(sandboxManager);
+});
+
+after(exports, (testName, assert, done) => {
+  driver = null;
+  sandboxManager.removeHold("test running");
+  sandboxManager.assertNuked(assert, done);
+});
+
+testRunner.run(exports);
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/test/test-env-expressions.js
@@ -0,0 +1,77 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * 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/. */
+
+"use strict";
+
+const {Cu} = require("chrome");
+Cu.import("resource://gre/modules/TelemetryController.jsm");
+Cu.import("resource://gre/modules/Task.jsm");
+const testRunner = require("sdk/test");
+
+Cu.import("resource://shield-recipe-client/lib/EnvExpressions.jsm");
+Cu.import("resource://shield-recipe-client/test/TestUtils.jsm");
+const {promiseTest} = TestUtils;
+
+exports["test it works"] = promiseTest(assert => {
+  return EnvExpressions.eval("2+2")
+  .then(val => assert.equal(val, 4, "env expression did not evaluate correctly"));
+});
+
+exports["test it evaluate multiline expressions"] = promiseTest(assert => {
+  return EnvExpressions.eval(`
+    2
+    +
+    2
+  `)
+  .then(val => assert.equal(val, 4, "multiline env expression did not evaluate correctly"));
+});
+
+exports["test it can access telemetry"] = promiseTest(assert => {
+  return EnvExpressions.eval("telemetry")
+  .then(telemetry => assert.ok(typeof telemetry === "object", "Telemetry does not exist"));
+});
+
+exports["test it reads different types of telemetry"] = promiseTest(Task.async(function* (assert) {
+  yield TelemetryController.submitExternalPing("testfoo", {foo: 1});
+  yield TelemetryController.submitExternalPing("testbar", {bar: 2});
+  const result = yield(EnvExpressions.eval("telemetry"));
+  assert.equal(result.testfoo.payload.foo, 1, "Could not read value foo from mock telemetry");
+  assert.equal(result.testbar.payload.bar, 2, "Could not read value bar from mock telemetry");
+}));
+
+exports["test has a date transform"] = promiseTest(assert => {
+  return EnvExpressions.eval('"2016-04-22"|date')
+  .then(val => {
+    const d = new Date(Date.UTC(2016, 3, 22)); // months are 0 based
+    assert.equal(val.toString(), d.toString(), "Date transform missing or broken");
+  });
+});
+
+exports["test dates are comparable"] = promiseTest(assert => {
+  let context = {someTime: Date.UTC(2016, 0, 1)};
+
+  return Promise.all([
+    EnvExpressions.eval('"2015-01-01"|date < someTime', context)
+      .then(val => assert.ok(val, "dates are comparable with less-than")),
+    EnvExpressions.eval('"2017-01-01"|date > someTime', context)
+      .then(val => assert.ok(val, "dates are comparable with greater-than")),
+  ]);
+});
+
+exports["test has a stable sample transform"] = promiseTest(assert => {
+  return EnvExpressions.eval('["test"]|stableSample(0.999)')
+  .then(val => assert.ok(val, "stableSample didn't accept known-working example"));
+});
+
+exports["test returns true for matching samples"] = promiseTest(assert => {
+  return EnvExpressions.eval('["test"]|stableSample(1)')
+  .then(val => assert.equal(val, true, "Stable sample returned false for 100% sample"));
+});
+
+exports["test returns false for matching samples"] = promiseTest(assert => {
+  return EnvExpressions.eval('["test"]|stableSample(0)')
+  .then(val => assert.equal(val, false, "Stable sample returned true for 0% sample"));
+});
+
+testRunner.run(exports);
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/test/test-storage.js
@@ -0,0 +1,66 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * 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/. */
+
+"use strict";
+
+const {Cu} = require("chrome");
+const testRunner = require("sdk/test");
+const {before, after} = require("sdk/test/utils");
+
+Cu.import("resource://shield-recipe-client/lib/Storage.jsm");
+Cu.import("resource://shield-recipe-client/test/TestUtils.jsm");
+const {promiseTest} = TestUtils;
+
+let store;
+
+exports["test set and get"] = promiseTest(assert => {
+  return store.setItem("key", "value")
+    .then(() => store.getItem("key"))
+    .then(value => {
+      assert.equal(value, "value", "storage returned wrong value");
+    });
+});
+
+exports["test value don't exist before set"] = promiseTest(assert => {
+  return store.getItem("absent")
+    .then(value => assert.equal(value, null), "storage returned non-null for missing key");
+});
+
+exports["test set and remove and get"] = promiseTest(assert => {
+  return store.setItem("removed", "value")
+    .then(() => store.removeItem("removed"))
+    .then(() => store.getItem("removed"))
+    .then(value => assert.equal(value, null, "removed value was not null"));
+});
+
+exports["test tests are independent 1 of 2"] = promiseTest(assert => {
+  return store.getItem("counter")
+    .then(value => store.setItem("counter", (value || 0) + 1))
+    .then(() => store.getItem("counter"))
+    .then(value => assert.equal(value, 1, "storage was not cleared between tests"));
+});
+
+exports["test tests are independent 2 of 2"] = promiseTest(assert => {
+  return store.getItem("counter")
+    .then(value => store.setItem("counter", (value || 0) + 1))
+    .then(() => store.getItem("counter"))
+    .then(value => assert.equal(value, 1, "storage was not cleared between tests"));
+});
+
+before(exports, () => {
+  let fakeSandbox = {Promise};
+  store = Storage.makeStorage("prefix", fakeSandbox);
+});
+
+after(exports, (name, assert, done) => {
+  store.clear()
+    .catch(err => {
+      assert.ok(false, err);
+    })
+    .then(() => {
+      done();
+    });
+});
+
+testRunner.run(exports);
--- a/layout/tools/reftest/reftest-preferences.js
+++ b/layout/tools/reftest/reftest-preferences.js
@@ -54,16 +54,17 @@ user_pref("layout.interruptible-reflow.e
 // Tell the search service we are running in the US.  This also has the
 // desired side-effect of preventing our geoip lookup.
 user_pref("browser.search.isUS", true);
 user_pref("browser.search.countryCode", "US");
 user_pref("browser.search.geoSpecificDefaults", false);
 
 // Make sure SelfSupport doesn't hit the network.
 user_pref("browser.selfsupport.url", "https://localhost/selfsupport-dummy/");
+user_pref("extensions.shield-recipe-client@mozilla.org.api_url", "https://localhost/selfsupport-dummy/");
 
 // use about:blank, not browser.startup.homepage
 user_pref("browser.startup.page", 0);
 
 // Allow XUL and XBL files to be opened from file:// URIs
 user_pref("dom.allow_XUL_XBL_for_file", true);
 
 // Allow view-source URIs to be opened from URIs that share
--- a/testing/profiles/prefs_general.js
+++ b/testing/profiles/prefs_general.js
@@ -301,18 +301,19 @@ user_pref("browser.uitour.url", "http://
 
 // Tell the search service we are running in the US.  This also has the desired
 // side-effect of preventing our geoip lookup.
 user_pref("browser.search.isUS", true);
 user_pref("browser.search.countryCode", "US");
 // This will prevent HTTP requests for region defaults.
 user_pref("browser.search.geoSpecificDefaults", false);
 
-// Make sure the self support tab doesn't hit the network.
+// Make sure self support doesn't hit the network.
 user_pref("browser.selfsupport.url", "https://%(server)s/selfsupport-dummy/");
+user_pref("extensions.shield-recipe-client@mozilla.org.api_url", "https://%(server)s/selfsupport-dummy/");
 
 user_pref("media.eme.enabled", true);
 
 user_pref("media.autoplay.enabled", true);
 
 // Don't use auto-enabled e10s
 user_pref("browser.tabs.remote.autostart.1", false);
 user_pref("browser.tabs.remote.autostart.2", false);
--- a/testing/talos/talos/config.py
+++ b/testing/talos/talos/config.py
@@ -138,16 +138,18 @@ DEFAULTS = dict(
             'http://127.0.0.1/extensions-dummy/repositoryBrowseURL',
         'extensions.getAddons.search.url':
             'http://127.0.0.1/extensions-dummy/repositorySearchURL',
         'media.gmp-manager.url':
             'http://127.0.0.1/gmpmanager-dummy/update.xml',
         'media.gmp-manager.updateEnabled': False,
         'extensions.systemAddon.update.url':
             'http://127.0.0.1/dummy-system-addons.xml',
+        'extensions.shield-recipe-client@mozilla.org.api_url':
+            'https://127.0.0.1/selfsupport-dummy/',
         'media.navigator.enabled': True,
         'media.peerconnection.enabled': True,
         'media.navigator.permission.disabled': True,
         'media.capturestream_hints.enabled': True,
         'browser.contentHandlers.types.0.uri': 'http://127.0.0.1/rss?url=%s',
         'browser.contentHandlers.types.1.uri': 'http://127.0.0.1/rss?url=%s',
         'browser.contentHandlers.types.2.uri': 'http://127.0.0.1/rss?url=%s',
         'browser.contentHandlers.types.3.uri': 'http://127.0.0.1/rss?url=%s',
--- a/testing/xpcshell/head.js
+++ b/testing/xpcshell/head.js
@@ -1603,27 +1603,28 @@ try {
     // Always use network provider for geolocation tests
     // so we bypass the OSX dialog raised by the corelocation provider
     let prefs = Components.classes["@mozilla.org/preferences-service;1"]
       .getService(Components.interfaces.nsIPrefBranch);
 
     prefs.setBoolPref("geo.provider.testing", true);
   }
 } catch (e) { }
-
 // We need to avoid hitting the network with certain components.
 try {
   if (runningInParent) {
     let prefs = Components.classes["@mozilla.org/preferences-service;1"]
       .getService(Components.interfaces.nsIPrefBranch);
 
     prefs.setCharPref("media.gmp-manager.url.override", "http://%(server)s/dummy-gmp-manager.xml");
     prefs.setCharPref("media.gmp-manager.updateEnabled", false);
     prefs.setCharPref("extensions.systemAddon.update.url", "http://%(server)s/dummy-system-addons.xml");
     prefs.setCharPref("browser.selfsupport.url", "https://%(server)s/selfsupport-dummy/");
+    prefs.setCharPref("extensions.shield-recipe-client@mozilla.org.api_url",
+                      "https://%(server)s/selfsupport-dummy/");
     prefs.setCharPref("toolkit.telemetry.server", "https://%(server)s/telemetry-dummy");
     prefs.setCharPref("browser.search.geoip.url", "https://%(server)s/geoip-dummy");
   }
 } catch (e) { }
 
 // Make tests run consistently on DevEdition (which has a lightweight theme
 // selected by default).
 try {