daemon: build velox-nmhost, systemd socket activation + units, velox(1) man page
Closes build order items 7 (the systemd half) and 9 (D11 in deferrals.md). velox-nmhost (nmhost/src/main.cpp, 185 lines): a poll()-driven byte pump between Firefox's native-messaging framing on stdio (4-byte native-byte-order length prefix) and veloxd's own NDJSON framing on the Unix socket. Reframes each direction, no JSON parsing, no retry/backoff (the extension relaunches a fresh host on its own reconnect), exits the moment either side closes. Deliberately dependency-free — no veloxd_* library, no nlohmann_json — since it runs unconfined outside Firefox's sandbox regardless of packaging format. Two real bugs found and fixed while getting the integration test to actually pass rather than hang, both exactly the class of bug a "trivial pump" invites: 1. Never set the pumped fds non-blocking, so the "drain what's available" read loop blocked on its own second read() instead of returning to poll(). 2. stdin and stdout are two different descriptors (0 and 1), not one — an early draft polled POLLOUT on fd 0, which is opened read-only, so EOF and writability were never both observable through the same pollfd entry. packaging/nativehost/com.velox.host.json + its own README.md supersede AGENT-DAEMON.md's stale "four locations" line: spike S1 / ADR 0003 found only three manifest locations are real (~/.mozilla/native-messaging-hosts/ for BOTH deb/tarball and snap Firefox, /usr/lib/mozilla/... for deb/tarball only, the flatpak sandbox path) — the fourth, ~/snap/firefox/common/.mozilla/..., is not read by snap Firefox at all. The README spells out the per-user-manifest / postinst enumeration implication for PKG/QA (postinst runs once as root; the two ~/-relative locations are per-user) and flags that docs/07-packaging.md's own install-layout line only shows the one root-owned path. Socket activation: rpc/systemd_activation.cpp is a from-scratch sd_listen_fds() (env vars only — LISTEN_PID/LISTEN_FDS, fd 3 — no libsystemd link) that UdsServer::start() checks first, skipping its own create/bind/chmod/listen when systemd already bound the socket. packaging/systemd/velox.socket + velox.service are the unit pair, verified both by systemd-analyze verify and by an actual fork/dup2/execve simulation of the activation handshake — a real session.hello round-tripped over the handed-off fd with no bind() ever called inside the daemon for that run. velox.service deliberately skips ProtectSystem=/ProtectHome=/ReadWritePaths=: saveTo.allowedRoots is user-configurable to anywhere on the filesystem, and a sandbox here would turn a legitimately-configured save location into an opaque EROFS/EACCES instead of the daemon's own clear -32011. cli/man/velox.1 documents the CLI as it actually exists today (add/ls/pause/resume/rm, --json) — the queue/settings subcommands AGENT-DAEMON.md's build order originally sketched aren't implemented in cli/src/main.cpp yet, so the page doesn't claim they are. Checked warning-free with groff -mandoc -ww -z. Full ctest: 57/57 (excluding the pre-existing, unrelated conformance failure noted in earlier commits). Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01GRDjHGgpYmMoPE2UFbe7pP
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
# packaging/nativehost — the Firefox native-messaging manifest
|
||||
|
||||
Owner: lane DAEMON (`packaging/nativehost/**` is explicitly DAEMON's per `CLAUDE.md`'s
|
||||
lane table, unlike the rest of `packaging/`, which is PKG/QA's). This directory holds the
|
||||
manifest `velox-nmhost` (built from `nmhost/`) is registered under, and this file is the
|
||||
one place that says exactly which on-disk locations need it and why — the
|
||||
"install manifests to all four locations" line in `docs/agents/AGENT-DAEMON.md`'s build
|
||||
order predates `docs/adr/0003-native-messaging-under-snap.md`, which cut that down to
|
||||
three *real* ones. Read the ADR before touching this list; it is the empirical spike, not
|
||||
a guess.
|
||||
|
||||
## The manifest
|
||||
|
||||
`com.velox.host.json` in this directory is the template:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "com.velox.host",
|
||||
"description": "Velox download manager — native messaging bridge to veloxd",
|
||||
"path": "/usr/libexec/velox/velox-nmhost",
|
||||
"type": "stdio",
|
||||
"allowed_extensions": ["[email protected]"]
|
||||
}
|
||||
```
|
||||
|
||||
- `name` is what the extension calls `browser.runtime.connectNative("com.velox.host")`
|
||||
with (`extension/src/background/transport/native.ts`'s `HOST_NAME`) — do not rename one
|
||||
without the other.
|
||||
- `allowed_extensions` must match `extension/manifest.json`'s
|
||||
`browser_specific_settings.gecko.id` exactly (`[email protected]`). A mismatch here is
|
||||
a silent failure: Firefox reports "no such native application" as if the manifest didn't
|
||||
exist at all, which reads exactly like a missing-file bug and is easy to chase in the
|
||||
wrong place.
|
||||
- `path` is `/usr/libexec/velox/velox-nmhost` — the `.deb`'s install layout
|
||||
(`docs/07-packaging.md`). **Every other packaging format needs a different `path`** —
|
||||
the binary doesn't live at that absolute path inside a Flatpak sandbox or an AppImage
|
||||
mount, so whatever installs the manifest for those formats must rewrite this field to
|
||||
wherever it actually put the binary, not ship this file verbatim. That substitution is
|
||||
the packaging step's job, not this template's.
|
||||
|
||||
## Where it has to be written, and why
|
||||
|
||||
Per ADR 0003 (spike S1, run against the real snap Firefox on the target machine — not a
|
||||
guess about how confinement "should" work):
|
||||
|
||||
| Location | Covers | Why |
|
||||
|---|---|---|
|
||||
| `~/.mozilla/native-messaging-hosts/com.velox.host.json` | **deb/tarball Firefox, and snap Firefox** | The one location snap Firefox 154 actually reads. Firefox's snap launches native-messaging hosts *outside* the sandbox with the user's real `$HOME` — so this is not "the deb location that happens to also work"; it is *the* snap location, full stop. `~/snap/firefox/common/.mozilla/native-messaging-hosts/` (the intuitive "inside the snap" path) is **not** read by Firefox 154 snap — confirmed empirically, not inferred. |
|
||||
| `/usr/lib/mozilla/native-messaging-hosts/com.velox.host.json` | **deb/tarball Firefox only** | System-wide, so it covers every user on the box for a real (non-snap) Firefox install — but confirmed **not** read by snap Firefox. Do not treat this path as "covers snap too"; that was the wrong assumption `docs/05` §4 corrected in the same commit as the ADR. |
|
||||
| `~/.var/app/org.mozilla.firefox/.mozilla/native-messaging-hosts/com.velox.host.json` | **Flatpak Firefox** | Flatpak's own sandboxed home. Untested on this machine (Firefox here is the snap, not flatpak) — carried over from `docs/05` §4's original four-location list, which this table otherwise supersedes. Verify before relying on it in a release checklist. |
|
||||
|
||||
That is three real locations, not four — the fourth
|
||||
(`~/snap/firefox/common/.mozilla/native-messaging-hosts/`) was the pre-ADR guess the spike
|
||||
disproved. If `docs/agents/AGENT-DAEMON.md`'s build order still says "four locations" when
|
||||
you read this, it is stale; this table is the current source of truth alongside the ADR
|
||||
itself.
|
||||
|
||||
## What this means for `postinst` (PKG/QA's file, not this one)
|
||||
|
||||
This directory ships the manifest template and documents the target paths; it does not
|
||||
install anything itself (`packaging/**` outside this one directory is PKG/QA's — see
|
||||
`CLAUDE.md`'s lane table — and `postinst` specifically is a `.deb`-packaging concern this
|
||||
lane doesn't own the file for). For whoever writes it:
|
||||
|
||||
- **The two `~/`-relative locations are per-user.** `postinst` runs as root, once, at
|
||||
install time — it does not run once per user session. It needs either a real-user
|
||||
enumeration at install time (every UID with a home directory and no login shell of
|
||||
`/usr/sbin/nologin`-style exclusions, roughly what `deluser --remove-home` scripts already
|
||||
have to reason about) or a first-run hook that runs as the logged-in user (a systemd user
|
||||
unit's `ExecStartPre`, or the GUI's own first-run wizard) and writes its own manifest the
|
||||
first time it starts. `docs/adr/0003`'s own follow-up section flagged this as open; it
|
||||
still is.
|
||||
- **`/usr/lib/mozilla/native-messaging-hosts/` is the only one of the three that's a plain
|
||||
root-owned, install-time write** — no per-user enumeration needed for that one.
|
||||
- **Detect snap Firefox and say so.** `docs/07-packaging.md` already commits to this
|
||||
("detects whether Firefox is a snap... prints... a one-line note that the extension will
|
||||
pair over loopback") — the detection matters here specifically because if Firefox turns
|
||||
out to be neither deb/tarball nor a snap this spike covers (a genuinely unknown or future
|
||||
packaging of Firefox), silently trusting `NativeTransport` to work is exactly the failure
|
||||
mode ADR 0003 exists to prevent. `WebSocketTransport` is the guaranteed fallback either
|
||||
way (ADR 0003's own decision) — nothing breaks if the manifest doesn't land correctly, it
|
||||
just means the extension pairs over loopback instead of the (opportunistic, not
|
||||
required) native path.
|
||||
- **`docs/07-packaging.md`'s own install layout line currently lists only the
|
||||
system-wide `/usr/lib/mozilla/...` path.** That line is correct as far as it goes (it *is*
|
||||
one of the three locations, and the only pure root-owned one) but reads as if it were the
|
||||
whole story for native messaging; it predates this file and the ADR. Worth a line
|
||||
pointing here so the two documents don't quietly disagree — PKG/QA's call, not edited
|
||||
here since `docs/07` is PKG/QA's own file.
|
||||
|
||||
## The binary
|
||||
|
||||
`nmhost/` builds `velox-nmhost` — see that directory's own `src/main.cpp` for what it does
|
||||
(a byte-level pump, no protocol logic) and `docs/05-extension-spec.md` §4 for the two
|
||||
transports it sits behind. It is deliberately dependency-free (not linked against any
|
||||
`veloxd_*` library) so wherever a packaging format's sandbox puts it, it has nothing else
|
||||
to go looking for at runtime.
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"name": "com.velox.host",
|
||||
"description": "Velox download manager — native messaging bridge to veloxd",
|
||||
"path": "/usr/libexec/velox/velox-nmhost",
|
||||
"type": "stdio",
|
||||
"allowed_extensions": ["[email protected]"]
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
# packaging/systemd — the user unit pair for veloxd
|
||||
|
||||
Provided by lane DAEMON (`daemon/docs/AGENT-DAEMON.md` build step 7: "systemd user units
|
||||
— `velox.service` + `velox.socket` for socket activation"), for PKG/QA to install per
|
||||
`docs/07-packaging.md`'s layout:
|
||||
|
||||
```
|
||||
/usr/lib/systemd/user/velox.service
|
||||
/usr/lib/systemd/user/velox.socket
|
||||
```
|
||||
|
||||
`packaging/` outside `nativehost/` is PKG/QA's per `CLAUDE.md`'s lane table; these two
|
||||
files are here because they are inputs to that packaging step, not a claim on the rest of
|
||||
the directory — same relationship `packaging/nativehost/` already has.
|
||||
|
||||
## Why both files, and what `RuntimeDirectory=` is doing
|
||||
|
||||
`velox.socket` binds `$XDG_RUNTIME_DIR/velox/velox.sock` **before `veloxd` ever runs** and
|
||||
hands the daemon the already-listening fd at startup (`daemon/src/rpc/systemd_activation.cpp`
|
||||
implements the receiving half — `LISTEN_PID`/`LISTEN_FDS`, fd 3 — without a `libsystemd`
|
||||
link). Two things this buys over the daemon binding its own socket on every start:
|
||||
|
||||
- **No window where a client gets `ECONNREFUSED`.** The socket exists and queues
|
||||
connections from the moment `velox.socket` is active, not from whenever `veloxd`
|
||||
finishes starting up — this is the actual point of socket activation, not just "start
|
||||
on demand."
|
||||
- **Cold-boot ordering is free.** Nothing has to wait for `veloxd` to be ready before the
|
||||
GUI, the CLI, or a native-messaging host can attempt a connection; the kernel queues it.
|
||||
|
||||
`RuntimeDirectory=velox` on the socket unit creates `%t/velox` (mode 0700) before the
|
||||
`ListenStream=` bind — without it, binding fails outright the first time (nothing has
|
||||
created the parent directory yet). `veloxd` itself creates that same directory
|
||||
(`ensure_private_dir` in `runtime_dir.cpp`) for the case where it's started directly,
|
||||
outside systemd (`./veloxd` in a terminal, still supported and how most of this daemon's
|
||||
own testing runs) — the two paths converge on the same directory with the same mode
|
||||
either way.
|
||||
|
||||
`UdsServer::start()` (`daemon/src/rpc/uds_server.cpp`) checks for the activated fd first
|
||||
and, if present, skips create/bind/chmod/listen entirely — the socket file's lifecycle then
|
||||
belongs to the unit (including `RemoveOnStop=yes` on stop), not to the daemon. Falls back
|
||||
to binding its own socket exactly as before when not socket-activated (a manual run, or a
|
||||
distro that ships the daemon without the unit files).
|
||||
|
||||
## What was deliberately left out
|
||||
|
||||
`velox.service` does **not** set `ProtectSystem=`, `ProtectHome=`, or `ReadWritePaths=`.
|
||||
`saveTo.allowedRoots` is user-configurable to anywhere on the filesystem — an external
|
||||
drive, a second mount, anywhere `fs/safepath.hpp`'s own canonicalize-and-check accepts —
|
||||
not a fixed set of directories a unit file could enumerate ahead of time. A filesystem-level
|
||||
sandbox here would turn a legitimately-configured save location into an opaque
|
||||
`EROFS`/`EACCES` the daemon can't explain, in place of its own clear `-32011` — worse than
|
||||
no sandbox, specifically for a download manager. `NoNewPrivileges=yes` is kept: it has no
|
||||
such trade-off.
|
||||
|
||||
## Verifying socket activation without a real install
|
||||
|
||||
`systemd-analyze verify --user velox.service velox.socket` checks unit-file syntax (it
|
||||
will complain that `/usr/bin/veloxd` and the `velox(1)` man page don't exist on a dev
|
||||
box that hasn't installed the package — expected, not a unit bug). To exercise the actual
|
||||
activation handshake without installing anything: bind a Unix socket, `dup2` it onto fd 3,
|
||||
fork, set `LISTEN_PID=<child pid>` and `LISTEN_FDS=1` in the child's environment, clear
|
||||
`FD_CLOEXEC` on fd 3, and `execve` `veloxd` — a real `session.hello` round-trips over that
|
||||
fd with no `bind()`/`listen()` call ever happening inside the daemon for that run. This is
|
||||
exactly what `velox.socket`'s `Requires=`/`ExecStart` sequence does in production; systemd
|
||||
supplies the fd, `veloxd` doesn't know the difference.
|
||||
@@ -0,0 +1,38 @@
|
||||
[Unit]
|
||||
Description=Velox download manager daemon
|
||||
Documentation=man:velox(1)
|
||||
# Socket activation (velox.socket) means this unit does not need to be enabled or started
|
||||
# directly for the RPC transport to come up on demand — the first connection attempt after
|
||||
# boot starts veloxd with the listening socket already bound (see velox.socket's own
|
||||
# comment). Requires=/After= still matter for a manual `systemctl --user start velox`.
|
||||
Requires=velox.socket
|
||||
After=velox.socket
|
||||
|
||||
# Never more than one real instance for this user regardless of how it was started — the
|
||||
# abstract-socket single-instance lock (main.cpp, keyed off the resolved runtime dir) is
|
||||
# the actual enforcement; this just keeps systemd itself from racing two starts.
|
||||
StartLimitIntervalSec=60
|
||||
StartLimitBurst=5
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/bin/veloxd
|
||||
# main.cpp's SIGTERM handler stops the event loop and falls through to a clean shutdown
|
||||
# (flushes buffers, closes the store, releases the single-instance lock) — the default
|
||||
# KillSignal=SIGTERM and TimeoutStopSec are already the right shape for that; no
|
||||
# ExecStop/KillMode override needed.
|
||||
Restart=on-failure
|
||||
RestartSec=2
|
||||
|
||||
# Hardening deliberately stops here, not at ProtectSystem=/ProtectHome=/ReadWritePaths=:
|
||||
# saveTo.allowedRoots is user-configurable to anywhere (an external drive, a second
|
||||
# mount — fs/safepath.hpp is the daemon's own validation boundary, not a fixed set of
|
||||
# directories a unit file could enumerate up front). A filesystem-level sandbox here would
|
||||
# silently turn a legitimately-configured save location into an opaque EROFS/EACCES the
|
||||
# daemon can't explain, instead of its own clear -32011 — worse than no sandbox, for a
|
||||
# download manager specifically. NoNewPrivileges is free of that trade-off.
|
||||
NoNewPrivileges=yes
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
Also=velox.socket
|
||||
@@ -0,0 +1,28 @@
|
||||
[Unit]
|
||||
Description=Velox download manager — RPC socket
|
||||
|
||||
[Socket]
|
||||
# %t is $XDG_RUNTIME_DIR for a user unit — the exact path veloxd itself resolves
|
||||
# (daemon/src/rpc/runtime_dir.cpp's resolve_runtime_dir), and the exact path velox(1)
|
||||
# resolves too (default_socket_path() in cli/src/client.cpp). All three must agree; this
|
||||
# is the one line that has to.
|
||||
ListenStream=%t/velox/velox.sock
|
||||
|
||||
# RuntimeDirectory creates %t/velox (mode 0700, this user's own) before binding, so the
|
||||
# ListenStream= path above always has somewhere to land — veloxd itself does the same
|
||||
# thing (ensure_private_dir) when it creates the directory unassisted (the non-activated
|
||||
# path, e.g. a manual `veloxd` run outside systemd).
|
||||
RuntimeDirectory=velox
|
||||
RuntimeDirectoryMode=0700
|
||||
|
||||
# Same-UID-only, matching the socket's own authorization once a connection is accepted
|
||||
# (SO_PEERCRED, checked again in uds_server.cpp regardless of this mode bit — belt and
|
||||
# braces, not a substitute for it).
|
||||
SocketMode=0600
|
||||
|
||||
# The socket file belongs to systemd's socket-activation state, not to whatever's on disk
|
||||
# from a previous boot; remove it on stop so a stale entry never shadows the next start.
|
||||
RemoveOnStop=yes
|
||||
|
||||
[Install]
|
||||
WantedBy=sockets.target
|
||||
Reference in New Issue
Block a user