Files
vdm/docs/adr/0003-native-messaging-under-snap.md
T
samiandClaude Sonnet 5 90e2580b05 ext: S1 native-messaging spike (ADR 0003) + WebSocket transport
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
2026-09-10 01:17:43 +04:00

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:

  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 [email protected], 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 execs 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 wrapperweb-ext otherwise execs the inner binary directly and bypasses the sandbox:

    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/<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.

  3. 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 (with sudo) /usr/lib/mozilla/native-messaging-hosts/. Use a distinct <id> per location so precedence is not a variable.

  4. Probe extension $D/ext/ — MV2, browser_specific_settings.gecko.id = "[email protected]", permissions ["nativeMessaging","http://127.0.0.1/*"], background script that calls browser.runtime.sendNativeMessage(<id>, {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:

    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/<firefox-pid>/attr/currentsnap.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/<uid>.

  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.