Spike S1 — run on the target machine through real snap confinement
(apparmor snap.firefox.firefox enforced; web-ext's direct-exec of the inner
binary bypasses it, so runs were forced through `snap run firefox`):
- manifest in ~/.mozilla/native-messaging-hosts/ -> WORKS; host launched
unconfined with real $HOME and real $XDG_RUNTIME_DIR, bound a socket in
the real /run/user/<uid>. Corroborated by the machine's 1Password host.
- ~/snap/firefox/common/.mozilla/native-messaging-hosts/ -> not read
- /usr/lib/mozilla/native-messaging-hosts/ -> not read
- flatpak path -> N/A (snap Firefox)
Decision: WebSocket stays the default; native messaging is an opportunistic
upgrade taken only when its handshake succeeds. docs/05 §4 corrected in this
commit to point the snap manifest at ~/.mozilla and mark /usr/lib as
deb/tarball-only. ADR carries a self-contained reproduction; the scratch
harness has been removed.
transport/ (build order item 1):
- types.ts VeloxTransport interface + error taxonomy
- rpc.ts JSON-RPC id correlation, per-call deadline, AbortSignal
- backoff.ts exponential backoff with jitter
- discovery.ts 52000-52016 scan ordering (last-good port first)
- websocket.ts scan -> session.hello -> auto-pair (token in
storage.local) -> reconnect; -32001 fatal, refused/
rate-limited pairing latches needsPairing (no retry storm);
a mid-handshake drop aborts hello immediately
- native.ts connectNative(); distinguishes "not installed" (fatal,
lets the picker fall through) from a crash (reconnect)
- index.ts createTransport() runtime picker + persisted Options override
Toolchain: package.json / tsconfig (strict) / vitest; webextension-polyfill
mocked. 38 tests, incl. the WS suite against a real loopback ws server.
No manifest.json yet, so CI's extension-lint guard stays a no-op.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012Y9RU58hD1BuwP82DySUHk
10 KiB
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:
- Which of the four NM manifest locations can snap Firefox actually launch a host from?
- Can that launched host reach the real
$XDG_RUNTIME_DIR, whereveloxdputs 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_DIRand attempt tobind()anAF_UNIXsocket both in$XDG_RUNTIME_DIRand in the literal real/run/user/1000. - Throwaway MV2 extension (id
[email protected], permissionsnativeMessaging,storage,http://127.0.0.1/*) that callsruntime.sendNativeMessagefor all three ids on load and POSTs the outcomes to a localhost collector. No remote code. - Driven by
web-ext run10.6.0 against snap-confined headless Firefox.web-extby defaultexecs the inner binary/snap/firefox/8763/usr/lib/firefox/firefoxdirectly, which bypasses confinement; every result below is from runs forced throughexec snap run firefox "$@", withapparmor: 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, publishermozilla, last refreshed 15 days before the spike. snap connections firefox:home,network,network-bind,removable-mediaconnected;personal-files[dot-mozilla-firefox]connected but scoped read-only to$HOME/.mozilla/firefox(profile import only); the snap declares nonative-messagingplug.- 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 exactlycom.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:
WebSocketTransportstays the primary, default path. It works unconditionally (network-bindconnected), has no per-flavour packaging edge cases, and R1 already mandates it as the guaranteed path. EXT builds it first.NativeTransportships 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 canexec(/usr/lib/velox/…,/opt/velox/…); it runs unconfined. - The NM host, being unconfined with the real
$XDG_RUNTIME_DIR, connects straight toveloxd'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
.debpostinst 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
NativeTransportthe 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 intransport/index.tsmay 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.
-
Confinement wrapper —
web-extotherwiseexecs the inner binary directly and bypasses the sandbox:printf '#!/bin/sh\nexec snap run firefox "$@"\n' > "$D/ffwrap.sh" && chmod +x "$D/ffwrap.sh" -
Probe host
$D/s1host.py(chmod +x): on launch, append pid +os.environHOME/XDG_RUNTIME_DIRto$D/s1host.log; trysocket.socket(AF_UNIX).bind()at bothos.path.join(os.environ["XDG_RUNTIME_DIR"], "s.sock")and the literal/run/user/<uid>/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. -
Manifest — one per location under test, each
{"name": "<id>", "type": "stdio", "path": "$D/s1host.py", "allowed_extensions": ["[email protected]"]}, installed to:~/.mozilla/native-messaging-hosts/,~/snap/firefox/common/.mozilla/native-messaging-hosts/, and (withsudo)/usr/lib/mozilla/native-messaging-hosts/. Use a distinct<id>per location so precedence is not a variable. -
Probe extension
$D/ext/— MV2,browser_specific_settings.gecko.id = "[email protected]", permissions["nativeMessaging","http://127.0.0.1/*"], background script that callsbrowser.runtime.sendNativeMessage(<id>, {probe:1})for every id and POSTs{id: {ok, resp|error}}to a localhost collector (python3 -m http.serverhandler writing the body to a file). -
Run, confined and headless:
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=--headlessConfirm confinement with
cat /proc/<firefox-pid>/attr/current→snap.firefox.firefox (enforce). Read the collector file and$D/s1host.logfor the result. -
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())"'— expectEACCESon the real/run/user/<uid>. -
Clean up: remove every manifest installed in step 3 (the
/usr/libone needssudo;rmdir/usr/lib/mozilla{,/native-messaging-hosts}if you created them), delete the test profile from~/snap/firefox/common/.mozilla/firefox/, andrm -rf $D.