thunderbird-patch-review

Easy-to-use patch review interface for Thunderbird

Contribute: ~marcc/thunderbird-review-plugin@lists.sr.ht

git clone git://mccd.space/thunderbird-patch-review

commit aabd7fedef54b88917a05e15363bac261f69a664
parent 7897796e5d0450d869d899b7e272be10d3a7ccfc
Author: Pi Agent <agent@pi.local>
Date:   Sat, 18 Jul 2026 17:13:56 +0200

host: replace the installed native messaging host with a WebExtension Experiment

The native host was a separate install (host/install-host.sh +
$HOME/.mozilla/.../native-messaging-hosts/*.json) gating Apply, Browse...,
and Open editor, which read like a mandatory prerequisite and made the
eventual one-file install awkward to bake in.

Fold that privileged code into the XPI itself as a WebExtension Experiment
("patchHost") declared via experiment_apis in the manifest. Remove the

  - nativeMessaging permission,
  - extension/modules/native.js bridge,
  - host/ directory and its POSIX-sh host + installer,
  - tests/host-test.sh,

and route background.js through messenger.patchHost.* (ping/apply/
pickDir/openEditor) instead of sendNativeMessage. The experiment's
schema + implementation live under extension/api/patchHost/, the only
chrome-privileged code in the add-on; everything else stays sandboxed.

Update README, patch-review.1, AGENTS.md to drop the native-host install
step, and add manifest/experiment wiring checks to tests/run.mjs so the
privileged glue is regression-tested headlessly.

Diffstat:
MAGENTS.md | 21+++++++++++----------
MREADME | 81+++++++++++++++++++++++++++++++++----------------------------------------------
Aextension/api/patchHost/implementation.js | 198+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aextension/api/patchHost/schema.json | 63+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mextension/background.js | 34+++++++++++++++++++---------------
Mextension/manifest.json | 15++++++++++++---
Dextension/modules/native.js | 62--------------------------------------------------------------
Dhost/host-manifest.json.in | 7-------
Dhost/install-host.sh | 31-------------------------------
Dhost/review-host.sh | 235-------------------------------------------------------------------------------
Mpatch-review.1 | 83++++++++++++++++++++++++++++---------------------------------------------------
Dtests/host-test.sh | 244-------------------------------------------------------------------------------
Mtests/run.mjs | 30++++++++++++++++++++++++++++++
13 files changed, 396 insertions(+), 708 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
@@ -11,8 +11,8 @@ commits, each with a focused message describing exactly that concern.
 Scope of "atomic" here:
 
 - One logical change per commit (a fix, a feature, a refactor — not mixed).
-- The extension still loads and `tests/run.mjs` / `tests/host-test.sh` still
-  pass after every commit, not only at the tip of the series.
+- The extension still loads and `tests/run.mjs` still passes after every
+  commit, not only at the tip of the series.
 - The commit message subject line summarizes the single concern; the body,
   when needed, explains why.
 
@@ -21,16 +21,17 @@ Scope of "atomic" here:
 - `extension/` — the Thunderbird MailExtension (manifest v2, no build step).
   - `review/review.{html,css,js}` — the review tab UI.
   - `modules/` — pure modules: diff parsing, intraline highlight, reply
-    formatting, review storage, series collection, sourcehut, native bridge.
-  - `background.js` — background service worker driving the host and tabs.
-- `host/` — the native messaging host (`review-host.sh`, POSIX sh) and its
-  install scripts.
-- `tests/` — `run.mjs` exercises the pure modules against fixtures; generate
-  fixtures with `gen-fixtures.sh`; `host-test.sh` drives the host end-to-end.
+    formatting, review storage, series collection, sourcehut.
+  - `api/patchHost/` — the privileged WebExtension Experiment (schema +
+    implementation) behind Apply, Browse..., and Open editor. The only
+    chrome-privileged code in the add-on; everything else is sandboxed.
+  - `background.js` — background page driving the experiment and tabs.
+- `tests/` — `run.mjs` exercises the pure modules and the experiment's
+  manifest wiring against fixtures; generate fixtures with
+  `gen-fixtures.sh`.
 
 ## Running the tests
 
 ```
-node tests/run.mjs        # pure modules
-sh tests/host-test.sh     # native host, end-to-end
+node tests/run.mjs
 ```
 \ No newline at end of file
diff --git a/README b/README
@@ -4,9 +4,9 @@ NAME
        patch-review - review git patches from email inside Thunderbird
 
 SYNOPSIS
-       A Thunderbird MailExtension plus a native messaging host.  Open a patch
-       email, press Review, comment on hunks, send the review as a mailing-
-       list reply, and apply the series to a local repository with git-am(1).
+       A Thunderbird MailExtension.  Open a patch email, press Review, comment
+       on hunks, send the review as a mailing-list reply, and apply the series
+       to a local repository with git-am(1).
 
 DESCRIPTION
        patch-review brings a review interface to git-by-email workflows
@@ -28,46 +28,37 @@ DESCRIPTION
        cleared and the review tab closes.  The attached drop-down offers
        Preview in compose instead, which leaves the reply drafts open in
        compose windows.	 A reply that fails to send also stays open, so
-       nothing is lost.	 Apply series hands the raw patch messages to the
-       native host, which runs git am --3way in the configured repository.  On
-       conflict the host runs git am --abort and reports the output, leaving
-       the repository untouched.  Its drop-down offers Modify and apply --
-       apply, then open the touched files in your editor -- and Download
-       patchset -- save the raw series as one mbox file, ready for git am.
+       nothing is lost.	 Apply series runs git am --3way on the raw patch
+       messages in the configured repository.  On conflict git am --abort is
+       run and its output reported, leaving the repository untouched.  Its
+       drop-down offers Modify and apply -- apply, then open the touched files
+       in your editor -- and Download patchset -- save the raw series as one
+       mbox file, ready for git am.
 
 INSTALLATION
-       The extension is what you install from the Add-ons Manager; the
-       native host is separate and optional.  Detection, the review tab,
-       commenting, and Send review work with the extension alone.  The
-       native host is needed only for Apply (git am), the Browse... repo
-       chooser, and Open editor after an apply.  Install both if you want
-       those features.
+       Everything ships inside the extension: install it and you are done.
+       Apply (git am), the Browse...  repo chooser, and Open editor are
+       implemented by a small privileged part bundled with the add-on (a
+       WebExtension Experiment under extension/api/patchHost/), which is why
+       the Add-ons Manager warns that the add-on has full access to
+       Thunderbird.  Those three features need git(1) in $PATH; nothing else
+       has an external dependency.
 
        1. Extension (temporary, for development)
 	      In Thunderbird open Tools -> Developer Tools -> Debug Add-ons,
 	      choose Load Temporary Add-on..., and pick
-	      extension/manifest.json.  The add-on is dropped on restart,
-	      so use step 2 for a permanent install.
+	      extension/manifest.json.	The add-on is dropped on restart, so
+	      use step 2 for a permanent install.
 
        2. Extension (permanent)
-	      Zip the extension/ directory with manifest.json at the
-	      archive root, then install the .xpi from the Add-ons Manager
-	      gear menu (Install Add-on From File...).  From the project
-	      root:
+	      Zip the extension/ directory with manifest.json at the archive
+	      root, then install the .xpi from the Add-ons Manager gear menu
+	      (Install Add-on From File...).  From the project root:
 
-		      ( cd extension && zip -qrX ../patch-review.xpi . )
+	      (cd extension && zip -qrX ../patch-review.xpi .)
 
-	      The parentheses run the zip from inside extension/ so the
-	      archive holds manifest.json at its root, not under
-	      extension/.
-
-       3. Native host (optional)
-	      Run host/install-host.sh once to install the native messaging
-	      manifest into the per-user Mozilla and Thunderbird locations.
-	      The host itself is strict POSIX sh(1) and needs only POSIX
-	      utilities plus git(1).  Restart Thunderbird afterward so it is
-	      picked up.  Without it, Apply / Browse... / Open editor report
-	      that the host is unreachable; everything else still works.
+       The subshell runs the zip(1) from inside extension/ so the archive
+       holds manifest.json at its root, not under extension/.
 
 CONFIGURATION
        Open the extension's preferences:
@@ -87,10 +78,9 @@ CONFIGURATION
 	      the last path a series was applied to is used; it is remembered
 	      until changed.
 
-       Repository paths can be chosen with the Browse button, which opens a
-       native directory chooser through the host (zenity(1), kdialog(1), or
-       osascript(1), whichever is found); the path field stays editable either
-       way.
+       Repository paths can be chosen with the Browse button, which opens
+       Thunderbird's native directory chooser; the path field stays editable
+       either way.
 
 SOURCEHUT INTEGRATION
        When the series arrives via a sourcehut mailing list (a lists.sr.ht
@@ -120,19 +110,16 @@ FILES
 	      The MailExtension (manifest v2, vanilla ES modules, no build
 	      step).
 
-       host/review-host.sh
-	      Native messaging host.  Speaks the length-prefixed framing with
-	      dd(1) and od(1), decodes the base64 envelope with awk(1), and
-	      never parses JSON.
-
-       ~/.mozilla/native-messaging-hosts/email.coquand.patchreview.json
-	      Installed host manifest (also under ~/.thunderbird).
+       extension/api/patchHost/
+	      The privileged part (a WebExtension Experiment): runs git-am(1)
+	      and the editor through Thunderbird's Subprocess API and shows
+	      the directory chooser with nsIFilePicker.	 Everything outside
+	      this directory runs as ordinary sandboxed WebExtension code.
 
        tests/ node tests/run.mjs exercises the pure modules (subject parsing,
 	      diff parsing, reply formatting) against fixtures generated by
-	      git-format-patch(1); sh tests/host-test.sh drives the host end-
-	      to-end, including a conflicting apply.  Regenerate fixtures with
-	      tests/gen-fixtures.sh.
+	      git-format-patch(1), and checks the experiment's manifest
+	      wiring.  Regenerate fixtures with tests/gen-fixtures.sh.
 
 CAVEATS
        Patches arriving as attachments, HTML-only mail, and reviewing from a
diff --git a/extension/api/patchHost/implementation.js b/extension/api/patchHost/implementation.js
@@ -0,0 +1,198 @@
+// Privileged WebExtension Experiment behind Apply series, Browse... and
+// Open editor. It ships inside the .xpi, so installing the extension is the
+// whole install — there is no separate native messaging host.
+//
+// This file runs with full chrome privileges in Thunderbird's parent
+// process; everything privileged the add-on does lives here. It sticks to
+// long-stable platform APIs — Subprocess to run git and the editor,
+// nsIFilePicker for the directory chooser, IOUtils/PathUtils for the patch
+// workspace — and the fallbacks below cover the differences between
+// Thunderbird 115 and current.
+
+"use strict";
+
+/* globals ExtensionAPI, Services, IOUtils, PathUtils, AppConstants, ChromeUtils, Cc, Ci */
+
+// Platform modules moved from .jsm to .sys.mjs across the supported
+// Thunderbird range; try the ESM spelling first.
+function importModule(name) {
+  try {
+    return ChromeUtils.importESModule(`resource://gre/modules/${name}.sys.mjs`);
+  } catch (e) {
+    return ChromeUtils.import(`resource://gre/modules/${name}.jsm`);
+  }
+}
+
+const { Subprocess } = importModule("Subprocess");
+
+/** Run a command to completion, stderr folded into stdout. */
+async function run(command, args) {
+  const proc = await Subprocess.call({
+    command,
+    arguments: args,
+    stderr: "stdout",
+  });
+  let output = "";
+  let chunk;
+  while ((chunk = await proc.stdout.readString())) {
+    output += chunk;
+  }
+  const { exitCode } = await proc.wait();
+  return { exitCode, output };
+}
+
+async function findGit() {
+  try {
+    return await Subprocess.pathSearch("git", Subprocess.getEnvironment());
+  } catch (e) {
+    // Thunderbird launched from a desktop entry or .app bundle may see a
+    // minimal PATH; try the usual install locations before giving up.
+    for (const candidate of ["/usr/local/bin/git", "/opt/homebrew/bin/git", "/usr/bin/git"]) {
+      if (await IOUtils.exists(candidate)) {
+        return candidate;
+      }
+    }
+    throw new Error("git not found in PATH");
+  }
+}
+
+async function requireRepo(git, repo) {
+  if (!repo) {
+    throw new Error("no repository path given");
+  }
+  const check = await run(git, ["-C", repo, "rev-parse", "--is-inside-work-tree"]);
+  if (check.exitCode !== 0) {
+    throw new Error(`not a git repository: ${repo}`);
+  }
+}
+
+async function ping() {
+  const git = await findGit();
+  const version = await run(git, ["--version"]);
+  return { ok: true, git, version: version.output.trim() };
+}
+
+async function apply(repo, strategy, patches) {
+  const git = await findGit();
+  await requireRepo(git, repo);
+  if (!patches.length) {
+    throw new Error("no patches in request");
+  }
+
+  const work = PathUtils.join(
+    PathUtils.tempDir,
+    `patch-review.${Date.now()}.${Math.floor(Math.random() * 1e9)}`
+  );
+  await IOUtils.makeDirectory(work, { permissions: 0o700 });
+  try {
+    const args = ["-C", repo, "am"];
+    if (strategy !== "am") {
+      args.push("--3way");
+    }
+    for (const [i, patch] of patches.entries()) {
+      const file = PathUtils.join(work, `${String(i + 1).padStart(4, "0")}.patch`);
+      await IOUtils.writeUTF8(file, patch);
+      args.push(file);
+    }
+    const am = await run(git, args);
+    if (am.exitCode === 0) {
+      return { ok: true, output: am.output };
+    }
+    const abort = await run(git, ["-C", repo, "am", "--abort"]);
+    return {
+      ok: false,
+      output:
+        am.output +
+        abort.output +
+        "(git am --abort ran; the repository is back to its previous state)\n",
+    };
+  } finally {
+    IOUtils.remove(work, { recursive: true }).catch(() => {});
+  }
+}
+
+async function pickDir(start) {
+  const win = Services.wm.getMostRecentWindow(null);
+  if (!win) {
+    throw new Error("no window to attach the directory chooser to");
+  }
+  const picker = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker);
+  // nsIFilePicker.init took a window before Thunderbird ~125 and takes a
+  // BrowsingContext after; whichever the running version rejects, throws.
+  try {
+    picker.init(win.browsingContext, "Choose repository", Ci.nsIFilePicker.modeGetFolder);
+  } catch (e) {
+    picker.init(win, "Choose repository", Ci.nsIFilePicker.modeGetFolder);
+  }
+  if (start) {
+    try {
+      const dir = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile);
+      dir.initWithPath(start);
+      if (dir.exists() && dir.isDirectory()) {
+        picker.displayDirectory = dir;
+      }
+    } catch (e) {
+      // Not a usable start path; the picker opens at its default.
+    }
+  }
+  const rv = await new Promise((resolve) => picker.open(resolve));
+  if (rv !== Ci.nsIFilePicker.returnOK || !picker.file) {
+    return { ok: true, output: "", cancelled: true };
+  }
+  return { ok: true, output: picker.file.path };
+}
+
+async function openEditor(repo, editor, files) {
+  const git = await findGit();
+  await requireRepo(git, repo);
+  const env = Subprocess.getEnvironment();
+  let command = (editor || "").trim() || (env.EDITOR || "").trim();
+  if (!command) {
+    // Nothing configured anywhere: open the repository directory instead.
+    command = { macosx: "open", win: "start" }[AppConstants.platform] || "xdg-open";
+    files = ["."];
+  }
+
+  if (AppConstants.platform === "win") {
+    const line = [command, ...files.map((f) => `"${f}"`)].join(" ");
+    await Subprocess.call({
+      command: env.ComSpec || "C:\\Windows\\System32\\cmd.exe",
+      arguments: ["/s", "/c", line],
+      workdir: repo,
+      stderr: "ignore",
+    });
+  } else {
+    // The command may carry flags ("code -n"); let sh split it and append
+    // the files. Output goes to /dev/null so a chatty editor can never
+    // fill the pipe, and the editor is not waited on.
+    await Subprocess.call({
+      command: "/bin/sh",
+      arguments: ["-c", `exec ${command} "$@" >/dev/null 2>&1`, "patch-review-edit", ...files],
+      workdir: repo,
+      stderr: "ignore",
+    });
+  }
+  return { ok: true, output: `launched: ${command}` };
+}
+
+/** Errors become {ok: false, error} so callers get one uniform shape. */
+async function guarded(fn) {
+  try {
+    return await fn();
+  } catch (e) {
+    return { ok: false, error: e.message || String(e) };
+  }
+}
+
+var patchHost = class extends ExtensionAPI {
+  getAPI() {
+    return {
+      patchHost: {
+        ping: () => guarded(ping),
+        apply: (repo, strategy, patches) => guarded(() => apply(repo, strategy, patches)),
+        pickDir: (start) => guarded(() => pickDir(start)),
+        openEditor: (repo, editor, files) => guarded(() => openEditor(repo, editor, files)),
+      },
+    };
+  }
+};
diff --git a/extension/api/patchHost/schema.json b/extension/api/patchHost/schema.json
@@ -0,0 +1,63 @@
+[
+  {
+    "namespace": "patchHost",
+    "description": "Privileged helper behind Apply series, Browse... and Open editor: runs git am, shows a native directory chooser, and launches the editor.",
+    "functions": [
+      {
+        "name": "ping",
+        "type": "function",
+        "async": true,
+        "description": "Report whether git is reachable; resolves to {ok, git, version}.",
+        "parameters": []
+      },
+      {
+        "name": "apply",
+        "type": "function",
+        "async": true,
+        "description": "Run git am on the raw patch messages in the given repository; resolves to {ok, output}.",
+        "parameters": [
+          { "name": "repo", "type": "string" },
+          {
+            "name": "strategy",
+            "type": "string",
+            "description": "\"am3\" for git am --3way (the default), \"am\" for plain git am."
+          },
+          {
+            "name": "patches",
+            "type": "array",
+            "items": { "type": "string" },
+            "description": "Raw RFC822 patch messages, in series order."
+          }
+        ]
+      },
+      {
+        "name": "pickDir",
+        "type": "function",
+        "async": true,
+        "description": "Open a native directory chooser; resolves to {ok, output} or {ok, cancelled}.",
+        "parameters": [
+          {
+            "name": "start",
+            "type": "string",
+            "description": "Directory the chooser starts in; may be empty."
+          }
+        ]
+      },
+      {
+        "name": "openEditor",
+        "type": "function",
+        "async": true,
+        "description": "Launch the editor command from the repository with the given files as arguments.",
+        "parameters": [
+          { "name": "repo", "type": "string" },
+          {
+            "name": "editor",
+            "type": "string",
+            "description": "Shell command line; empty means $EDITOR, then a platform opener on the repository."
+          },
+          { "name": "files", "type": "array", "items": { "type": "string" } }
+        ]
+      }
+    ]
+  }
+]
diff --git a/extension/background.js b/extension/background.js
@@ -1,6 +1,6 @@
 // Background script: patch detection on displayed messages, opening the
 // review tab, and servicing requests from it (series data, compose replies,
-// native-host apply/edit).
+// apply/edit through the patchHost experiment).
 //
 // MV2 background pages can't be ES modules, so shared code is pulled in
 // with dynamic import().
@@ -15,7 +15,6 @@ function mods() {
     modulesPromise = Promise.all([
       import(messenger.runtime.getURL("modules/patch-detect.js")),
       import(messenger.runtime.getURL("modules/series.js")),
-      import(messenger.runtime.getURL("modules/native.js")),
       import(messenger.runtime.getURL("modules/sourcehut.js")),
     ]).then((loaded) => Object.assign({}, ...loaded));
   }
@@ -27,6 +26,19 @@ async function getConfig() {
   return Object.assign({ editor: "", strategy: "am3", mappings: [] }, found.config);
 }
 
+// Apply, the directory chooser, and the editor launch are backed by the
+// patchHost experiment bundled under api/patchHost/. It always ships with
+// the extension, but its privileged code can still fail to load on a
+// Thunderbird whose internals moved; turn that into a normal error reply.
+function host() {
+  if (!messenger.patchHost) {
+    throw new Error(
+      "the bundled patchHost helper did not load (incompatible Thunderbird version?)"
+    );
+  }
+  return messenger.patchHost;
+}
+
 // ---------------------------------------------------------------------------
 // Detection: the single toolbar ("Review") button lights up when the current
 // selection — or the displayed message, in standalone message tabs — looks
@@ -199,20 +211,12 @@ async function handle(request) {
       for (const messageId of request.messageIds) {
         patches.push(await rawMessage(messageId));
       }
-      return m.callHost(
-        "apply",
-        { repo: request.repo, strategy: config.strategy || "am3" },
-        patches
-      );
+      return host().apply(request.repo, config.strategy || "am3", patches);
     }
 
     case "open-editor": {
       const config = await getConfig();
-      return m.callHost("edit", {
-        repo: request.repo,
-        editor: config.editor || "",
-        files: (request.files || []).join("\n"),
-      });
+      return host().openEditor(request.repo, config.editor || "", request.files || []);
     }
 
     case "download-series": {
@@ -241,11 +245,11 @@ async function handle(request) {
     }
 
     case "pick-repo":
-      // Native directory chooser; the host reuses the repo field as start dir.
-      return m.callHost("pickdir", { repo: request.start || "" });
+      // Native directory chooser; the repo field doubles as its start dir.
+      return host().pickDir(request.start || "");
 
     case "ping-host":
-      return m.callHost("ping");
+      return host().ping();
 
     default:
       throw new Error(`unknown request: ${request.type}`);
diff --git a/extension/manifest.json b/extension/manifest.json
@@ -34,7 +34,16 @@
     "compose",
     "compose.send",
     "downloads",
-    "tabs",
-    "nativeMessaging"
-  ]
+    "tabs"
+  ],
+  "experiment_apis": {
+    "patchHost": {
+      "schema": "api/patchHost/schema.json",
+      "parent": {
+        "scopes": ["addon_parent"],
+        "paths": [["patchHost"]],
+        "script": "api/patchHost/implementation.js"
+      }
+    }
+  }
 }
diff --git a/extension/modules/native.js b/extension/modules/native.js
@@ -1,62 +0,0 @@
-// Bridge to the POSIX-sh native messaging host. The host deliberately avoids
-// a JSON parser, so we send it a single field {d: <base64>} containing a
-// line-based envelope; values that may contain arbitrary characters are
-// base64-encoded again inside the envelope:
-//
-//   action apply
-//   repo64 <base64 of repo path>
-//   editor64 <base64 of editor command>
-//   strategy am3
-//   patch64 <base64 of one raw patch message>   (repeated, in series order)
-//   end
-//
-// The host replies with real JSON ({ok, output, ...}), which Thunderbird
-// parses for us.
-
-export const HOST_NAME = "email.coquand.patchreview";
-
-/** base64 of the UTF-8 encoding of s, without line breaks. */
-export function b64(s) {
-  const bytes = new TextEncoder().encode(s);
-  let bin = "";
-  const CHUNK = 0x8000;
-  for (let i = 0; i < bytes.length; i += CHUNK) {
-    bin += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
-  }
-  return btoa(bin);
-}
-
-export function buildEnvelope(action, fields = {}, patches = []) {
-  const lines = [`action ${action}`];
-  for (const [name, value] of Object.entries(fields)) {
-    if (value !== undefined && value !== null && value !== "") {
-      lines.push(`${name}64 ${b64(String(value))}`);
-    }
-  }
-  for (const patch of patches) {
-    lines.push(`patch64 ${b64(patch)}`);
-  }
-  lines.push("end");
-  return lines.join("\n");
-}
-
-/**
- * One-shot request to the host.
- * @returns {Promise<{ok: boolean, output?: string, error?: string}>}
- */
-export async function callHost(action, fields = {}, patches = []) {
-  const envelope = buildEnvelope(action, fields, patches);
-  try {
-    const response = await messenger.runtime.sendNativeMessage(HOST_NAME, { d: b64(envelope) });
-    return response && typeof response === "object"
-      ? response
-      : { ok: false, error: "malformed host response" };
-  } catch (e) {
-    return {
-      ok: false,
-      error:
-        `Could not reach the native host "${HOST_NAME}". ` +
-        `Run host/install-host.sh and restart Thunderbird. (${e.message})`,
-    };
-  }
-}
diff --git a/host/host-manifest.json.in b/host/host-manifest.json.in
@@ -1,7 +0,0 @@
-{
-  "name": "email.coquand.patchreview",
-  "description": "Patch Review native host: applies patch series with git am and opens the editor",
-  "path": "@HOST_PATH@",
-  "type": "stdio",
-  "allowed_extensions": ["patchreview@coquand.email"]
-}
diff --git a/host/install-host.sh b/host/install-host.sh
@@ -1,31 +0,0 @@
-#!/bin/sh
-# Install the native messaging host manifest so Thunderbird can find
-# review-host.sh. POSIX sh only.
-set -eu
-
-dir=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd)
-host="$dir/review-host.sh"
-name="email.coquand.patchreview"
-
-chmod +x "$host"
-
-install_manifest() {
-    mkdir -p "$1"
-    sed "s|@HOST_PATH@|$host|" "$dir/host-manifest.json.in" > "$1/$name.json"
-    echo "installed $1/$name.json"
-}
-
-# Thunderbird's native messaging search path differs across versions and
-# packagings; installing the (tiny) manifest in both candidates is harmless.
-case $(uname) in
-    Darwin)
-        install_manifest "$HOME/Library/Mozilla/NativeMessagingHosts"
-        install_manifest "$HOME/Library/Application Support/Mozilla/NativeMessagingHosts"
-        ;;
-    *)
-        install_manifest "$HOME/.mozilla/native-messaging-hosts"
-        install_manifest "$HOME/.thunderbird/native-messaging-hosts"
-        ;;
-esac
-
-echo "Restart Thunderbird for the host to be picked up."
diff --git a/host/review-host.sh b/host/review-host.sh
@@ -1,235 +0,0 @@
-#!/bin/sh
-# Native messaging host for the Patch Review Thunderbird extension.
-#
-# Strict POSIX sh and POSIX utilities only (dd, od, awk, sed, tr, wc), plus
-# git. No jq: the extension sends {"d":"<base64>"} where the payload is a
-# line-based envelope (see extension/modules/native.js), so JSON never needs
-# to be parsed here — only produced, which printf and awk can do safely.
-#
-# Handles exactly one request per invocation, which is how
-# runtime.sendNativeMessage() drives a host.
-#
-# Intentional idioms shellcheck dislikes: single-quoted awk programs (SC2016),
-# two-stage printf for binary framing (SC2059), unquoted expansions used for
-# word splitting (SC2046/SC2086), trap-invoked cleanup (SC2329).
-# shellcheck disable=SC2016,SC2059,SC2046,SC2086,SC2329
-
-set -u
-LC_ALL=C
-export LC_ALL
-
-VERSION="0.1.0"
-
-# Native messaging protocol: stdout must carry nothing but framed responses.
-# Reserve fd 3 for responses and point stdout at stderr for everything else.
-exec 3>&1 1>&2
-
-# --- workspace --------------------------------------------------------------
-
-WORK=""
-cleanup() { [ -n "$WORK" ] && rm -rf "$WORK"; }
-trap cleanup EXIT INT TERM
-
-i=0
-while :; do
-    WORK="${TMPDIR:-/tmp}/patch-review.$$.$i"
-    if mkdir -m 700 "$WORK" 2>/dev/null; then
-        break
-    fi
-    i=$((i + 1))
-    if [ "$i" -gt 100 ]; then
-        echo "patch-review host: cannot create workspace" >&2
-        exit 1
-    fi
-done
-
-# --- awk helpers ------------------------------------------------------------
-
-AWK_B64DECODE='
-BEGIN {
-    chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
-    for (i = 1; i <= 64; i++) val[substr(chars, i, 1)] = i - 1
-}
-{
-    line = $0
-    gsub(/=+$/, "", line)
-    n = length(line)
-    for (i = 1; i <= n; i += 4) {
-        chunk = substr(line, i, 4)
-        len = length(chunk)
-        acc = val[substr(chunk, 1, 1)] * 262144 + val[substr(chunk, 2, 1)] * 4096
-        if (len >= 3) acc += val[substr(chunk, 3, 1)] * 64
-        if (len >= 4) acc += val[substr(chunk, 4, 1)]
-        printf "%c", int(acc / 65536) % 256
-        if (len >= 3) printf "%c", int(acc / 256) % 256
-        if (len >= 4) printf "%c", acc % 256
-    }
-}'
-
-AWK_JSON_ESCAPE='
-BEGIN { first = 1 }
-{
-    gsub(/\\/, "\\\\")
-    gsub(/"/, "\\\"")
-    gsub("\t", "\\t")
-    if (!first) printf "\\n"
-    printf "%s", $0
-    first = 0
-}'
-
-b64decode() { awk "$AWK_B64DECODE"; }
-
-json_escape() {
-    # Drop control characters except tab and newline, then escape for JSON.
-    tr -d '\000-\010\013-\037' | awk "$AWK_JSON_ESCAPE"
-}
-
-# --- framing ----------------------------------------------------------------
-
-respond() {
-    n=${#1}
-    # Inner printf renders the four little-endian length bytes as octal
-    # escape text; the outer printf turns them into real bytes (NUL included,
-    # which never survives command substitution, hence the two stages).
-    printf "$(printf '\\%03o\\%03o\\%03o\\%03o' \
-        $((n % 256)) $((n / 256 % 256)) $((n / 65536 % 256)) $((n / 16777216 % 256)))" >&3
-    printf %s "$1" >&3
-}
-
-fail() {
-    respond "{\"ok\":false,\"error\":\"$(printf %s "$1" | json_escape)\"}"
-    exit 0
-}
-
-# Read the 4-byte little-endian length header. bs=1 makes dd's reads exact.
-set -- $(dd bs=1 count=4 2>/dev/null | od -An -tu1)
-if [ $# -ne 4 ]; then
-    exit 0 # closed without a request
-fi
-LEN=$(($1 + $2 * 256 + $3 * 65536 + $4 * 16777216))
-if [ "$LEN" -le 0 ] || [ "$LEN" -gt 67108864 ]; then
-    fail "implausible message length: $LEN"
-fi
-
-# Read exactly LEN payload bytes; a pipe may deliver them in pieces.
-: > "$WORK/payload"
-got=0
-while [ "$got" -lt "$LEN" ]; do
-    dd bs=$((LEN - got)) count=1 2>/dev/null >> "$WORK/payload"
-    now=$(wc -c < "$WORK/payload")
-    if [ "$now" -eq "$got" ]; then
-        fail "unexpected EOF while reading request"
-    fi
-    got=$now
-done
-
-# --- unwrap the envelope ----------------------------------------------------
-
-case $(dd bs=6 count=1 if="$WORK/payload" 2>/dev/null) in
-    '{"d":"') ;;
-    *) fail "unrecognized request format" ;;
-esac
-
-sed 's/^{"d":"//; s/"}$//' "$WORK/payload" | b64decode > "$WORK/envelope"
-
-ACTION="" REPO="" EDITOR_CMD="" FILES="" STRATEGY="am3" PATCHES=0
-
-while IFS=' ' read -r keyword value || [ -n "$keyword" ]; do
-    case $keyword in
-        action) ACTION=$value ;;
-        repo64) REPO=$(printf %s "$value" | b64decode) ;;
-        editor64) EDITOR_CMD=$(printf %s "$value" | b64decode) ;;
-        files64) FILES=$(printf %s "$value" | b64decode) ;;
-        strategy64) STRATEGY=$(printf %s "$value" | b64decode) ;;
-        patch64)
-            PATCHES=$((PATCHES + 1))
-            printf %s "$value" | b64decode > "$WORK/$(printf %04d "$PATCHES").patch"
-            ;;
-        end) break ;;
-    esac
-done < "$WORK/envelope"
-
-# --- actions ----------------------------------------------------------------
-
-require_repo() {
-    if [ -z "$REPO" ]; then
-        fail "no repository path given"
-    fi
-    if ! git -C "$REPO" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
-        fail "not a git repository: $REPO"
-    fi
-}
-
-case $ACTION in
-    ping)
-        respond "{\"ok\":true,\"host\":\"patch-review\",\"version\":\"$VERSION\"}"
-        ;;
-
-    apply)
-        require_repo
-        if [ "$PATCHES" -eq 0 ]; then
-            fail "no patches in request"
-        fi
-        am_flags="--3way"
-        if [ "$STRATEGY" = "am" ]; then
-            am_flags=""
-        fi
-        if git -C "$REPO" am $am_flags "$WORK"/*.patch > "$WORK/log" 2>&1; then
-            respond "{\"ok\":true,\"output\":\"$(json_escape < "$WORK/log")\"}"
-        else
-            git -C "$REPO" am --abort >> "$WORK/log" 2>&1
-            echo "(git am --abort ran; the repository is back to its previous state)" >> "$WORK/log"
-            respond "{\"ok\":false,\"output\":\"$(json_escape < "$WORK/log")\"}"
-        fi
-        ;;
-
-    pickdir)
-        start=${REPO:-${HOME:-/}}
-        if command -v zenity >/dev/null 2>&1; then
-            dir=$(zenity --file-selection --directory \
-                --title="Choose repository" --filename="$start/" 2>/dev/null) || dir=""
-        elif command -v kdialog >/dev/null 2>&1; then
-            dir=$(kdialog --getexistingdirectory "$start" 2>/dev/null) || dir=""
-        elif command -v osascript >/dev/null 2>&1; then
-            dir=$(osascript -e \
-                'POSIX path of (choose folder with prompt "Choose repository")' \
-                2>/dev/null) || dir=""
-        else
-            fail "no directory chooser found (install zenity or kdialog); type the path instead"
-        fi
-        if [ -z "$dir" ]; then
-            respond '{"ok":true,"output":"","cancelled":true}'
-        else
-            respond "{\"ok\":true,\"output\":\"$(printf %s "$dir" | json_escape)\"}"
-        fi
-        ;;
-
-    edit)
-        require_repo
-        if [ -z "$EDITOR_CMD" ]; then
-            EDITOR_CMD=${EDITOR:-}
-        fi
-        if [ -z "$EDITOR_CMD" ]; then
-            EDITOR_CMD="xdg-open ."
-            FILES=""
-        fi
-        # Split the newline-separated file list without globbing.
-        set -f
-        oldifs=$IFS
-        IFS='
-'
-        set -- $FILES
-        IFS=$oldifs
-        set +f
-        # Detach: the editor must outlive this host process.
-        ( cd "$REPO" && nohup sh -c "$EDITOR_CMD \"\$@\"" patch-review-edit "$@" \
-            >/dev/null 2>&1 & )
-        respond "{\"ok\":true,\"output\":\"launched: $(printf %s "$EDITOR_CMD" | json_escape)\"}"
-        ;;
-
-    *)
-        fail "unknown action: $ACTION"
-        ;;
-esac
-
-exit 0
diff --git a/patch-review.1 b/patch-review.1
@@ -2,7 +2,7 @@
 .SH NAME
 patch\-review \- review git patches from email inside Thunderbird
 .SH SYNOPSIS
-A Thunderbird MailExtension plus a native messaging host.
+A Thunderbird MailExtension.
 Open a patch email, press
 .BR Review ,
 comment on hunks, send the review as a mailing-list reply, and apply the
@@ -37,12 +37,12 @@ The attached drop-down offers
 instead, which leaves the reply drafts open in compose windows.
 A reply that fails to send also stays open, so nothing is lost.
 .B Apply series
-hands the raw patch messages to the native host, which runs
+runs
 .B git am \-\-3way
-in the configured repository.
-On conflict the host runs
+on the raw patch messages in the configured repository.
+On conflict
 .B git am \-\-abort
-and reports the output, leaving the repository untouched.
+is run and its output reported, leaving the repository untouched.
 Its drop-down offers
 .I "Modify and apply"
 \(em apply, then open the touched files in your editor \(em and
@@ -50,19 +50,23 @@ Its drop-down offers
 \(em save the raw series as one mbox file, ready for
 .BR "git am" .
 .SH INSTALLATION
-The extension is what you install from the Add-ons Manager; the native
-host is separate and optional.
-Detection, the review tab, commenting, and
-.B "Send review"
-work with the extension alone.
-The native host is needed only for
+Everything ships inside the extension: install it and you are done.
 .B Apply
-.RB ( git am ),
+.RB ( "git am" ),
 the
 .I "Browse..."
 repo chooser, and
-.IR "Open editor " after an apply.
-Install both if you want those features.
+.I "Open editor"
+are implemented by a small privileged part bundled with the add-on (a
+WebExtension Experiment under
+.IR extension/api/patchHost/ ),
+which is why the Add-ons Manager warns that the add-on has full access to
+Thunderbird.
+Those three features need
+.BR git (1)
+in
+.BR $PATH ;
+nothing else has an external dependency.
 .TP
 1. Extension (temporary, for development)
 In Thunderbird open
@@ -95,24 +99,6 @@ so the archive holds
 .B manifest.json
 at its root, not under
 .IR extension/ .
-.TP
-3. Native host (optional)
-Run
-.B host/install\-host.sh
-once to install the native messaging manifest into the per-user Mozilla
-and Thunderbird locations.
-The host itself is strict POSIX
-.BR sh (1)
-and needs only POSIX utilities plus
-.BR git (1).
-Restart Thunderbird afterward so it is picked up.
-Without it,
-.BR Apply ,
-.IR "Browse..." ,
-and
-.I "Open editor"
-report that the host is unreachable;
-everything else still works.
 .SH CONFIGURATION
 Open the extension's preferences:
 .TP
@@ -140,12 +126,8 @@ it is remembered until changed.
 .PP
 Repository paths can be chosen with the
 .B Browse
-button, which opens a native directory chooser through the host
-.RB ( zenity (1),
-.BR kdialog (1),
-or
-.BR osascript (1),
-whichever is found); the path field stays editable either way.
+button, which opens Thunderbird's native directory chooser; the path field
+stays editable either way.
 .SH SOURCEHUT INTEGRATION
 When the series arrives via a sourcehut mailing list (a
 .B lists.sr.ht
@@ -178,27 +160,20 @@ carrying just the header.
 .I extension/
 The MailExtension (manifest v2, vanilla ES modules, no build step).
 .TP
-.I host/review\-host.sh
-Native messaging host.
-Speaks the length-prefixed framing with
-.BR dd (1)
-and
-.BR od (1),
-decodes the base64 envelope with
-.BR awk (1),
-and never parses JSON.
-.TP
-.I ~/.mozilla/native\-messaging\-hosts/email.coquand.patchreview.json
-Installed host manifest (also under
-.IR ~/.thunderbird ).
+.I extension/api/patchHost/
+The privileged part (a WebExtension Experiment): runs
+.BR git\-am (1)
+and the editor through Thunderbird's Subprocess API and shows the
+directory chooser with nsIFilePicker.
+Everything outside this directory runs as ordinary sandboxed WebExtension
+code.
 .TP
 .I tests/
 .B node tests/run.mjs
 exercises the pure modules (subject parsing, diff parsing, reply
 formatting) against fixtures generated by
-.BR git\-format\-patch (1);
-.B sh tests/host\-test.sh
-drives the host end-to-end, including a conflicting apply.
+.BR git\-format\-patch (1),
+and checks the experiment's manifest wiring.
 Regenerate fixtures with
 .BR tests/gen\-fixtures.sh .
 .SH CAVEATS
diff --git a/tests/host-test.sh b/tests/host-test.sh
@@ -1,244 +0,0 @@
-#!/bin/sh
-# End-to-end tests for host/review-host.sh, speaking the real native
-# messaging framing. POSIX sh + POSIX utilities only:
-#   sh tests/host-test.sh
-#
-# Intentional idioms shellcheck dislikes: single-quoted awk programs, the
-# binary framing printf, od word splitting, and $? of test conditions.
-# shellcheck disable=SC2016,SC2059,SC2046,SC2319
-set -u
-LC_ALL=C
-export LC_ALL
-
-dir=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd)
-host="$dir/../host/review-host.sh"
-work="${TMPDIR:-/tmp}/patch-review-hosttest.$$"
-trap 'rm -rf "$work"' EXIT INT TERM
-mkdir -p "$work"
-
-failures=0
-check() { # $1 name, $2 = 0/1 pass
-    if [ "$2" -eq 0 ]; then
-        echo "ok   $1"
-    else
-        failures=$((failures + 1))
-        echo "FAIL $1" >&2
-    fi
-}
-
-# --- base64 encoder (single line, no wrapping) ------------------------------
-
-AWK_B64ENCODE='
-BEGIN {
-    chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
-    for (i = 0; i < 256; i++) ord[sprintf("%c", i)] = i
-    nbuf = 0
-}
-function put(b) {
-    buf[nbuf++] = b
-    if (nbuf == 3) flush3()
-}
-function flush3() {
-    acc = buf[0] * 65536 + buf[1] * 256 + buf[2]
-    printf "%s%s%s%s", substr(chars, int(acc / 262144) + 1, 1), \
-        substr(chars, int(acc / 4096) % 64 + 1, 1), \
-        substr(chars, int(acc / 64) % 64 + 1, 1), \
-        substr(chars, acc % 64 + 1, 1)
-    nbuf = 0
-}
-{
-    n = length($0)
-    for (i = 1; i <= n; i++) put(ord[substr($0, i, 1)])
-    put(10) # the newline awk stripped
-}
-END {
-    if (nbuf == 1) {
-        acc = buf[0] * 65536
-        printf "%s%s==", substr(chars, int(acc / 262144) + 1, 1), \
-            substr(chars, int(acc / 4096) % 64 + 1, 1)
-    } else if (nbuf == 2) {
-        acc = buf[0] * 65536 + buf[1] * 256
-        printf "%s%s%s=", substr(chars, int(acc / 262144) + 1, 1), \
-            substr(chars, int(acc / 4096) % 64 + 1, 1), \
-            substr(chars, int(acc / 64) % 64 + 1, 1)
-    }
-}'
-
-b64encode() { awk "$AWK_B64ENCODE"; }
-
-# --- framing ----------------------------------------------------------------
-
-send_request() { # $1 = envelope file; framed request on stdout
-    json="{\"d\":\"$(b64encode < "$1")\"}"
-    n=${#json}
-    printf "$(printf '\\%03o\\%03o\\%03o\\%03o' \
-        $((n % 256)) $((n / 256 % 256)) $((n / 65536 % 256)) $((n / 16777216 % 256)))"
-    printf %s "$json"
-}
-
-run_host() { # $1 = envelope file, $2 = response output file; honors EXTRA_PATH
-    env_file=$1
-    resp_file=$2
-    send_request "$env_file" | PATH="${EXTRA_PATH:+$EXTRA_PATH:}$PATH" sh "$host" \
-        > "$resp_file.raw" 2> "$resp_file.err"
-    # set -- consumes the function arguments, hence the copies above.
-    set -- $(od -An -tu1 -N4 "$resp_file.raw")
-    if [ $# -ne 4 ]; then
-        echo "(no framed response)" > "$resp_file"
-        return 1
-    fi
-    expected=$(($1 + $2 * 256 + $3 * 65536 + $4 * 16777216))
-    dd if="$resp_file.raw" ibs=1 skip=4 2>/dev/null > "$resp_file"
-    actual=$(wc -c < "$resp_file")
-    [ "$actual" -eq "$expected" ]
-}
-
-# --- scratch repos ----------------------------------------------------------
-
-setup_repo() {
-    git -C "$1" config user.email t@example.org
-    git -C "$1" config user.name Tester
-    git -C "$1" config commit.gpgsign false
-}
-
-upstream="$work/upstream"
-git init -q "$upstream"
-setup_repo "$upstream"
-echo "one" > "$upstream/file.txt"
-git -C "$upstream" add file.txt
-git -C "$upstream" commit -q -m "initial"
-
-contributor="$work/contributor"
-git clone -q "$upstream" "$contributor"
-setup_repo "$contributor"
-echo "two" >> "$contributor/file.txt"
-git -C "$contributor" add file.txt
-git -C "$contributor" commit -q -m "add second line"
-git -C "$contributor" format-patch -q -1 -o "$work/patches"
-patch_file=$(ls "$work/patches"/0001-*)
-
-# --- tests ------------------------------------------------------------------
-
-# ping
-printf 'action ping\nend\n' > "$work/env-ping"
-run_host "$work/env-ping" "$work/resp-ping"
-check "host: ping framing" $?
-grep -q '"ok":true' "$work/resp-ping"
-check "host: ping ok" $?
-
-# apply
-{
-    printf 'action apply\n'
-    printf 'repo64 %s\n' "$(printf %s "$upstream" | b64encode)"
-    printf 'strategy64 %s\n' "$(printf %s am3 | b64encode)"
-    printf 'patch64 %s\n' "$(b64encode < "$patch_file")"
-    printf 'end\n'
-} > "$work/env-apply"
-run_host "$work/env-apply" "$work/resp-apply"
-check "host: apply framing" $?
-grep -q '"ok":true' "$work/resp-apply"
-check "host: apply ok" $?
-[ "$(git -C "$upstream" log --format=%s -1)" = "add second line" ]
-check "host: commit landed" $?
-grep -q "two" "$upstream/file.txt"
-check "host: file content applied" $?
-
-# a conflicting patch must fail, and git am --abort must leave the repo clean
-echo "conflicting upstream change" > "$upstream/file.txt"
-git -C "$upstream" add file.txt
-git -C "$upstream" commit -q -m "upstream diverges"
-
-echo "contributor version" > "$contributor/file.txt"
-git -C "$contributor" add file.txt
-git -C "$contributor" commit -q -m "contributor change"
-git -C "$contributor" format-patch -q -1 -o "$work/patches2"
-conflict_patch=$(ls "$work/patches2"/0001-*)
-
-before=$(git -C "$upstream" rev-parse HEAD)
-{
-    printf 'action apply\n'
-    printf 'repo64 %s\n' "$(printf %s "$upstream" | b64encode)"
-    printf 'patch64 %s\n' "$(b64encode < "$conflict_patch")"
-    printf 'end\n'
-} > "$work/env-conflict"
-run_host "$work/env-conflict" "$work/resp-conflict"
-check "host: conflict framing" $?
-grep -q '"ok":false' "$work/resp-conflict"
-check "host: conflict rejected" $?
-[ "$(git -C "$upstream" rev-parse HEAD)" = "$before" ] && \
-    [ -z "$(git -C "$upstream" status --porcelain)" ]
-check "host: repo clean after failed apply" $?
-
-# apply refuses a non-repo
-{
-    printf 'action apply\n'
-    printf 'repo64 %s\n' "$(printf %s "$work" | b64encode)"
-    printf 'patch64 %s\n' "$(b64encode < "$patch_file")"
-    printf 'end\n'
-} > "$work/env-norepo"
-run_host "$work/env-norepo" "$work/resp-norepo"
-grep -q '"ok":false' "$work/resp-norepo" && grep -q 'not a git repository' "$work/resp-norepo"
-check "host: rejects non-repo" $?
-
-# edit launches the configured command detached
-marker="$work/editor-ran"
-{
-    printf 'action edit\n'
-    printf 'repo64 %s\n' "$(printf %s "$upstream" | b64encode)"
-    printf 'editor64 %s\n' "$(printf %s "touch $marker" | b64encode)"
-    printf 'files64 %s\n' "$(printf %s "file.txt" | b64encode)"
-    printf 'end\n'
-} > "$work/env-edit"
-run_host "$work/env-edit" "$work/resp-edit"
-check "host: edit framing" $?
-grep -q '"ok":true' "$work/resp-edit"
-check "host: edit ok" $?
-tries=0
-while [ ! -f "$marker" ] && [ "$tries" -lt 50 ]; do
-    tries=$((tries + 1))
-    sleep 0.1 2>/dev/null || sleep 1
-done
-[ -f "$marker" ]
-check "host: editor command ran" $?
-
-# pickdir uses the first available chooser; fake zenity so no GUI is needed
-fake="$work/fakebin"
-mkdir -p "$fake"
-cat > "$fake/zenity" <<EOF
-#!/bin/sh
-printf '%s\n' "/picked/repo path"
-EOF
-chmod +x "$fake/zenity"
-
-printf 'action pickdir\nend\n' > "$work/env-pick"
-EXTRA_PATH=$fake
-run_host "$work/env-pick" "$work/resp-pick"
-check "host: pickdir framing" $?
-grep -q '"ok":true' "$work/resp-pick" && grep -q '/picked/repo path' "$work/resp-pick"
-check "host: pickdir returns chosen path" $?
-
-# cancelled chooser (zenity exits nonzero) reports ok + cancelled, no path
-cat > "$fake/zenity" <<EOF
-#!/bin/sh
-exit 1
-EOF
-run_host "$work/env-pick" "$work/resp-pick-cancel"
-grep -q '"cancelled":true' "$work/resp-pick-cancel"
-check "host: pickdir cancel" $?
-EXTRA_PATH=""
-
-# unknown action
-printf 'action bogus\nend\n' > "$work/env-bogus"
-run_host "$work/env-bogus" "$work/resp-bogus"
-grep -q '"ok":false' "$work/resp-bogus"
-check "host: unknown action rejected" $?
-
-# --- summary ----------------------------------------------------------------
-
-if [ "$failures" -gt 0 ]; then
-    echo "" >&2
-    echo "$failures failure(s)" >&2
-    exit 1
-fi
-echo ""
-echo "all host tests passed"
diff --git a/tests/run.mjs b/tests/run.mjs
@@ -336,6 +336,36 @@ function check(name, cond, extra = "") {
   check("reply: empty without comments", formatReview(p, {}) === "");
 }
 
+// --- patchHost experiment wiring --------------------------------------------
+// The privileged code only runs inside Thunderbird, but the manifest glue
+// and the script's syntax can be checked here.
+
+{
+  const root = join(here, "..", "extension");
+  const manifest = JSON.parse(readFileSync(join(root, "manifest.json"), "utf8"));
+  const experiment = (manifest.experiment_apis || {}).patchHost;
+  check("experiment: declared in the manifest", Boolean(experiment));
+  check(
+    "experiment: nativeMessaging permission gone",
+    !(manifest.permissions || []).includes("nativeMessaging")
+  );
+
+  const schema = JSON.parse(readFileSync(join(root, experiment.schema), "utf8"));
+  const names = schema[0].functions.map((f) => f.name).sort().join(",");
+  check("experiment: schema functions", names === "apply,openEditor,pickDir,ping");
+  check("experiment: schema functions async", schema[0].functions.every((f) => f.async));
+
+  const source = readFileSync(join(root, experiment.parent.script), "utf8");
+  let parses = true;
+  try {
+    new Function(source);
+  } catch (e) {
+    parses = false;
+  }
+  check("experiment: implementation parses", parses);
+  check("experiment: defines the patchHost class", /\bvar patchHost\s*=/.test(source));
+}
+
 // --- summary ----------------------------------------------------------------
 
 if (failures) {