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
This commit is contained in:
2026-09-10 01:17:43 +04:00
co-authored by Claude Sonnet 5
parent 9025085d82
commit 90e2580b05
25 changed files with 6509 additions and 3 deletions
+8 -3
View File
@@ -93,11 +93,16 @@ interface Transport {
`browser.runtime.connectNative("com.velox.host")``velox-nmhost` → Unix socket.
Manifest installed to **all** of these, because the right one depends on how Firefox was
installed:
- `~/.mozilla/native-messaging-hosts/com.velox.host.json` (deb/tarball)
- `~/snap/firefox/common/.mozilla/native-messaging-hosts/com.velox.host.json` (snap)
- `/usr/lib/mozilla/native-messaging-hosts/com.velox.host.json` (system-wide)
- `~/.mozilla/native-messaging-hosts/com.velox.host.json` (deb/tarball **and snap**
spike S1 proved snap Firefox reads the real home here, not `$SNAP_USER_COMMON`; see
`docs/adr/0003-native-messaging-under-snap.md`)
- `/usr/lib/mozilla/native-messaging-hosts/com.velox.host.json` (system-wide; deb/tarball
only — snap Firefox does not read it, per S1)
- `~/.var/app/org.mozilla.firefox/.mozilla/native-messaging-hosts/` (flatpak)
The host binary runs **outside** the snap sandbox with the real `$HOME` and real
`$XDG_RUNTIME_DIR`, so it reaches `veloxd`'s Unix socket directly (ADR 0003 §Q2).
### B. `WebSocketTransport` (fallback, guaranteed to work under snap confinement)
`ws://127.0.0.1:<port>` where the port is discovered by trying a small fixed range and
verifying a `session.hello` handshake — the extension cannot read
@@ -0,0 +1,174 @@
# 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 `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/<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": ["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 `<id>`
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(<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:
```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/<firefox-pid>/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/<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`.