# ADR 0003 — Native messaging under snap Firefox (spike S1) **Status:** accepted · **Date:** 2026-09-10 · **Lane:** EXT ## Context `docs/06` R1 flags snap-confined Firefox as a HIGH risk to the extension transport: a native-messaging (NM) host that lives outside the snap has a long history of not launching, and even when it does, of running under the snap's constraints. R1's decision rule needs an empirical answer to two questions before M2: 1. Which of the four NM manifest locations can snap Firefox actually launch a host from? 2. Can that launched host reach the real `$XDG_RUNTIME_DIR`, where `veloxd` puts its socket? S1's brief assumes a clean 26.04 VM. No VM was available; the spike was run on the target machine itself. "Clean VM" is read here as *uncontaminated state* — so the machine's pre-existing NM state is recorded below in place of the guarantee a fresh image would have given, and every change made was reversible and has been reverted. ## What was tested - Throwaway host-id family `com.example.s1probe.{realhome,snapcommon,usrlib}`, one id per candidate location so precedence is not a variable. Trivial Python echo hosts under `/home/sami/vdm-s1-probe/` that, on launch, record their own `$HOME` / `$XDG_RUNTIME_DIR` and attempt to `bind()` an `AF_UNIX` socket both in `$XDG_RUNTIME_DIR` and in the literal real `/run/user/1000`. - Throwaway MV2 extension (id `s1probe@velox.test`, permissions `nativeMessaging`, `storage`, `http://127.0.0.1/*`) that calls `runtime.sendNativeMessage` for all three ids on load and POSTs the outcomes to a localhost collector. No remote code. - Driven by `web-ext run` 10.6.0 against **snap-confined** headless Firefox. `web-ext` by default `exec`s the inner binary `/snap/firefox/8763/usr/lib/firefox/firefox` directly, which bypasses confinement; every result below is from runs forced through `exec snap run firefox "$@"`, with `apparmor: snap.firefox.firefox (enforce)` verified on the launched browser process. - The browser's own confined view was captured separately via `snap run --shell firefox`. ## Results | Manifest location | Host launched? | Host `$HOME` | Host `$XDG_RUNTIME_DIR` | Bind in real `/run/user/1000`? | |---|---|---|---|---| | `~/.mozilla/native-messaging-hosts/` (real home) | **yes** (2 runs) | `/home/sami` (real) | `/run/user/1000` (real) | **yes** | | `~/snap/firefox/common/.mozilla/native-messaging-hosts/` (`$SNAP_USER_COMMON`) | **no** (3 runs) — `"No such native application"` | — | — | — | | `/usr/lib/mozilla/native-messaging-hosts/` (system-wide) | **no** (1 run) — `"No such native application"`, manifest confirmed on disk `root:root 0644` | — | — | — | | `~/.var/app/org.mozilla.firefox/.mozilla/native-messaging-hosts/` (flatpak) | **N/A** — Firefox here is the snap, not flatpak; tree absent | — | — | — | **Q1 — which location:** only the real `~/.mozilla/native-messaging-hosts/`. Corroborated by the machine's working 1Password integration: its manifest sits in that same directory and its host socket `1Password-BrowserSupport.sock` is present in the real `/run/user/1000`. The `$SNAP_USER_COMMON` path — the one `docs/05` §4.A currently calls *the* snap location — is not read by Firefox 154 snap. **Q2 — can the host reach `$XDG_RUNTIME_DIR`:** yes. Firefox's snap launches NM hosts **outside** the sandbox. The host inherits the real, unconfined environment (real `$HOME`, real `$XDG_RUNTIME_DIR=/run/user/1000`) and can `bind()`/`connect()` an `AF_UNIX` socket there. This is the opposite of the *browser* process's own view. Via `snap run --shell firefox`: - browser: `apparmor: snap.firefox.firefox (enforce)`, `$HOME=$SNAP_USER_COMMON`, `$XDG_RUNTIME_DIR=/run/user/1000/snap.firefox`; - from inside: can bind under the snap-private `$XDG_RUNTIME_DIR`; **EACCES** binding or writing under the real `/run/user/1000`; **EACCES** even listing `~/.mozilla/native-messaging-hosts/`. So `veloxd`'s socket at `$XDG_RUNTIME_DIR/velox/velox.sock` (real) is reachable by the NM host, though it would be unreachable from inside the browser sandbox. ### Machine state recorded in lieu of a clean VM - Ubuntu 26.04.1 LTS, kernel 7.0.0-31-generic, snapd 2.76.3, `snap debug confinement` = `strict`, bare metal (not a container). - Firefox: snap, track `latest/stable/ubuntu-26.04`, **rev 8763**, version **154.0-1**, publisher `mozilla`, last refreshed 15 days before the spike. - `snap connections firefox`: `home`, `network`, `network-bind`, `removable-media` connected; `personal-files[dot-mozilla-firefox]` connected but scoped **read-only to `$HOME/.mozilla/firefox`** (profile import only); the snap declares **no `native-messaging` plug**. - Legacy-home mode (`MOZ_LEGACY_HOME=1`); active profile `~/snap/firefox/common/.mozilla/firefox/utz2ej6e.default`. Real `~/.mozilla/firefox/` does not exist. - Pre-existing NM state: `~/.mozilla/native-messaging-hosts/` contained exactly `com.1password.1password.json`. `~/snap/firefox/common/.mozilla/native-messaging-hosts/`, `/usr/lib/mozilla/`, and the flatpak tree did **not** exist. All test artifacts in those locations were removed afterward; `~/.mozilla/native-messaging-hosts/` was left with only the pre-existing 1Password file. ## Decision Native messaging **works** on snap Firefox on this configuration, but narrowly: the manifest must be in the user's real `~/.mozilla/native-messaging-hosts/`. Applying R1's decision rule: - **`WebSocketTransport` stays the primary, default path.** It works unconditionally (`network-bind` connected), has no per-flavour packaging edge cases, and R1 already mandates it as the guaranteed path. EXT builds it first. - **`NativeTransport` ships as an opportunistic upgrade, not the default.** `velox-nmhost` + manifest are still shipped for deb/tarball/flatpak Firefox and for snap Firefox where the manifest lands in the right place. - For **snap** Firefox the manifest goes to **`~/.mozilla/native-messaging-hosts/com.velox.host.json`**, not the `~/snap/...` path. The host binary may live anywhere the unconfined launcher can `exec` (`/usr/lib/velox/…`, `/opt/velox/…`); it runs unconfined. - The NM host, being unconfined with the real `$XDG_RUNTIME_DIR`, connects straight to `veloxd`'s Unix socket. No cross-namespace bridge is needed on this configuration. ### Follow-ups - **docs/05 §4** — corrected in the same commit as this ADR: the snap manifest goes to `~/.mozilla/native-messaging-hosts/`, and `/usr/lib/...` is deb/tarball-only. - **PKG/QA** (open, not EXT's files): the `.deb` postinst must install the snap NM manifest per-user under `~/.mozilla/native-messaging-hosts/` (postinst runs as root — needs a real-user enumeration or a first-run/user-systemd step) and must **not** treat `/usr/lib/mozilla/native-messaging-hosts/` as covering snap Firefox. The installer's "detect which Firefox is in use and say so" requirement (R1) stands. ## Alternatives rejected - **Manifest in `$SNAP_USER_COMMON/.mozilla/native-messaging-hosts/`.** Intuitive — it is "inside the snap" — but empirically not read by Firefox 154 snap. - **`/usr/lib/mozilla/native-messaging-hosts/` as the one system-wide install for every Firefox flavour.** Does not work for snap Firefox. Retained only for deb/tarball Firefox. - **Make `NativeTransport` the default when a snap is detected.** The WS path is strictly simpler to get right across snap/deb/flatpak, and R1 already requires it as the guaranteed path. NM remains an optimization that the runtime picker in `transport/index.ts` may prefer *when its handshake actually succeeds*, never on detection alone. ## Reproduction Self-contained; run in a scratch dir `$D` (all files outside any snap-hidden path so the sandbox can read them). Takes ~2 min. 1. **Confinement wrapper** — `web-ext` otherwise `exec`s the inner binary directly and bypasses the sandbox: ```sh printf '#!/bin/sh\nexec snap run firefox "$@"\n' > "$D/ffwrap.sh" && chmod +x "$D/ffwrap.sh" ``` 2. **Probe host** `$D/s1host.py` (`chmod +x`): on launch, append pid + `os.environ` `HOME`/`XDG_RUNTIME_DIR` to `$D/s1host.log`; try `socket.socket(AF_UNIX).bind()` at both `os.path.join(os.environ["XDG_RUNTIME_DIR"], "s.sock")` and the literal `/run/user//s.sock`; then do the native-messaging handshake (read 4-byte LE length + JSON on stdin, write the same framing back) echoing the probe results. 3. **Manifest** — one per location under test, each `{"name": "", "type": "stdio", "path": "$D/s1host.py", "allowed_extensions": ["s1probe@velox.test"]}`, installed to: `~/.mozilla/native-messaging-hosts/`, `~/snap/firefox/common/.mozilla/native-messaging-hosts/`, and (with `sudo`) `/usr/lib/mozilla/native-messaging-hosts/`. Use a distinct `` per location so precedence is not a variable. 4. **Probe extension** `$D/ext/` — MV2, `browser_specific_settings.gecko.id = "s1probe@velox.test"`, permissions `["nativeMessaging","http://127.0.0.1/*"]`, background script that calls `browser.runtime.sendNativeMessage(, {probe:1})` for every id and POSTs `{id: {ok, resp|error}}` to a localhost collector (`python3 -m http.server` handler writing the body to a file). 5. **Run**, confined and headless: ```sh npx --yes web-ext@10 run --source-dir="$D/ext" --firefox="$D/ffwrap.sh" \ --firefox-profile="$D/ff-profile" --profile-create-if-missing --keep-profile-changes \ --no-input --no-reload --args=--headless ``` Confirm confinement with `cat /proc//attr/current` → `snap.firefox.firefox (enforce)`. Read the collector file and `$D/s1host.log` for the result. 6. **Browser-side view** (Q2's other half): `snap run --shell firefox -c 'echo $HOME; echo $XDG_RUNTIME_DIR; python3 -c "import socket,os; socket.socket(socket.AF_UNIX).bind(\"/run/user/%d/x.sock\" % os.getuid())"'` — expect `EACCES` on the real `/run/user/`. 7. **Clean up**: remove every manifest installed in step 3 (the `/usr/lib` one needs `sudo`; `rmdir` `/usr/lib/mozilla{,/native-messaging-hosts}` if you created them), delete the test profile from `~/snap/firefox/common/.mozilla/firefox/`, and `rm -rf $D`.