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:
+129
@@ -0,0 +1,129 @@
|
||||
.TH VELOX 1 "2026-09-12" "Velox" "User Commands"
|
||||
.SH NAME
|
||||
velox \- command-line client for veloxd, the Velox download manager daemon
|
||||
.SH SYNOPSIS
|
||||
.B velox
|
||||
.I command
|
||||
.RI [ options ]
|
||||
.SH DESCRIPTION
|
||||
.B velox
|
||||
talks to
|
||||
\fBveloxd\fR
|
||||
over its Unix-domain socket and gives a scriptable, terminal-first view of the same
|
||||
downloads the GUI and the Firefox extension see. It does no downloading itself \(em every
|
||||
command is a thin JSON-RPC call; the daemon owns all state.
|
||||
.PP
|
||||
.B veloxd
|
||||
must already be running (see
|
||||
.B ENVIRONMENT
|
||||
below for how
|
||||
.B velox
|
||||
finds it, and
|
||||
.BR systemctl (1)
|
||||
.RI ( "systemctl --user status velox" )
|
||||
for whether it's up). If nothing is listening on the socket,
|
||||
.B velox
|
||||
exits with status 3 rather than hanging.
|
||||
.SH COMMANDS
|
||||
.TP
|
||||
.BI "add " url " [\-\-dir " dir "] [\-\-out " name "] [\-\-segments " n ]
|
||||
Add a new download. Prints the new task's id and initial state.
|
||||
.RS
|
||||
.TP
|
||||
.BI "\-\-dir " dir
|
||||
Destination directory. Must resolve inside one of the daemon's configured
|
||||
.B saveTo.allowedRoots
|
||||
or the call fails; omit to use the configured default download directory.
|
||||
.TP
|
||||
.BI "\-\-out " name
|
||||
Filename to save as. Omit to derive one from the URL (or, once the daemon has probed it,
|
||||
from the server's own
|
||||
.BR Content-Disposition ).
|
||||
.TP
|
||||
.BI "\-\-segments " n
|
||||
Requested connection count for this download, 1\(en32. The daemon may use fewer \(em a
|
||||
per-host cap or a source that turns out not to support resuming both lower it. The
|
||||
.B ls
|
||||
table (and
|
||||
.RI "\-\-json's " segments
|
||||
field) show what was actually granted, not what was asked for.
|
||||
.RE
|
||||
.TP
|
||||
.B ls
|
||||
List every download the daemon knows about: id, state, progress, and filename. With
|
||||
.BR \-\-json ", the raw " download.list " result instead of the table."
|
||||
.TP
|
||||
.BI "pause " id " [" id " ...]"
|
||||
Pause one or more downloads by id. A download already paused, or already finished, is
|
||||
left alone \(em not an error.
|
||||
.TP
|
||||
.BI "resume " id " [" id " ...]"
|
||||
Resume one or more paused downloads.
|
||||
.TP
|
||||
.BI "rm " id " [" id " ...] " "[\-\-delete\-file]"
|
||||
Remove one or more downloads from the list. Without
|
||||
.BR \-\-delete\-file ,
|
||||
any partial data on disk
|
||||
.RI ( .veloxpart / .veloxpart.meta )
|
||||
is discarded but a
|
||||
.B completed
|
||||
file is left in place. With
|
||||
.BR \-\-delete\-file ,
|
||||
the finished file is deleted too \(em there is deliberately no default for this flag; you
|
||||
must say which you mean every time.
|
||||
.SH OPTIONS
|
||||
.TP
|
||||
.B \-\-json
|
||||
Print the raw JSON-RPC result instead of a formatted table. Works with every command;
|
||||
combine with
|
||||
.BR jq (1)
|
||||
for scripting. On error, the JSON form is an
|
||||
.B {"error": {...}}
|
||||
object on stdout rather than a message on stderr.
|
||||
.TP
|
||||
.B \-h ", " \-\-help
|
||||
Print usage and exit 0.
|
||||
.SH EXIT STATUS
|
||||
.TP
|
||||
.B 0
|
||||
Success.
|
||||
.TP
|
||||
.B 1
|
||||
The daemon reached the call but returned a JSON-RPC error (bad task id, path outside the
|
||||
allowed roots, and so on). The message is on stderr, or in the JSON error object with
|
||||
.BR \-\-json .
|
||||
.TP
|
||||
.B 2
|
||||
Usage error \(em missing argument, unknown option, or unknown command.
|
||||
.TP
|
||||
.B 3
|
||||
Could not reach
|
||||
.B veloxd
|
||||
at all: not running, or its socket is missing or unreachable.
|
||||
.SH ENVIRONMENT
|
||||
.TP
|
||||
.B XDG_RUNTIME_DIR
|
||||
.B velox
|
||||
connects to
|
||||
.IR "$XDG_RUNTIME_DIR/velox/velox.sock" .
|
||||
If unset, it falls back to
|
||||
.IR /run/user/ <uid> ,
|
||||
matching
|
||||
\fBveloxd\fR's
|
||||
own resolution \(em the two must agree for the client to find the daemon, so this is
|
||||
normally left to the desktop session's default rather than set by hand.
|
||||
.SH FILES
|
||||
.TP
|
||||
.I $XDG_RUNTIME_DIR/velox/velox.sock
|
||||
The daemon's Unix-domain socket, mode 0600, same-UID only (\fBSO_PEERCRED\fR checked on
|
||||
every connection \(em this is the transport's authorization, not an extra login).
|
||||
.SH SEE ALSO
|
||||
.BR systemctl (1),
|
||||
.BR jq (1)
|
||||
.PP
|
||||
.I docs/01-architecture.md
|
||||
and
|
||||
.I docs/agents/AGENT-DAEMON.md
|
||||
in the Velox source tree for the daemon's own build order and the wire protocol
|
||||
.B velox
|
||||
speaks.
|
||||
@@ -75,6 +75,7 @@ target_link_libraries(veloxd_sched PUBLIC velox::proto velox::core veloxd_store
|
||||
add_library(veloxd_rpc STATIC
|
||||
src/rpc/runtime_dir.cpp
|
||||
src/rpc/single_instance.cpp
|
||||
src/rpc/systemd_activation.cpp
|
||||
src/rpc/event_loop.cpp
|
||||
src/rpc/event_hub.cpp
|
||||
src/rpc/uds_server.cpp
|
||||
|
||||
@@ -7,7 +7,7 @@ close. Kept here (not buried in commit messages) so the next pass can see them a
|
||||
|---|---|---|---|---|
|
||||
| ~~D7~~ | **Closed — `capture.offer` is real.** Applies `capture.enabled`/`excludedHosts`/`monitoredExtensions`/`monitoredMimeTypes`/`minSizeBytes` from settings, then the rules table (`store::Rules` + CORE's `vdm::rules::match_rules`/`glob_match` — DAEMON only converts its own stored `proto::Rule` JSON into CORE's plain `vdm::rules::Rule` vocabulary, per that header's own layering note), resolves the category folder (a rule's explicit `categoryId`/`saveDir`, else `store::Categories::guess_by_extension` — the same extension-guess `download.probe`'s `suggestedCategoryId` already used, now shared instead of duplicated), dedupes against active (non-terminal) tasks by exact URL, and on `take` calls `add_one()` — the same path `download.add` itself uses — so a captured download is a real, admitted, persisted task, not a special case. The 750 ms deadline (CLAUDE.md §4 / AGENT-DAEMON.md build step 6) is checked cooperatively between every step via a new `rpc::CaptureDataSource` seam (real impl wraps `store::*`; a test fake can jump its own clock forward to simulate "the store was slow just now" with zero real sleep) — catches the realistic failure mode (several slow steps adding up) though it can't preempt one pathologically stuck single call. Verified against real `veloxd` + `tools/testserver`: a monitored-type offer answers in ~5ms and actually creates + downloads the task; an unmonitored type, an excluded host, a rule-vetoed host, and a second offer for a still-active URL all answer `ignore` with the right `reason`; a bad category save dir surfaces its real `-32011` rather than being swallowed. New `capture_offer_test` covers all of the above plus the deadline itself (two cases, one per "slow" checkpoint), asserting real wall-clock time barely moves even though the fake clock jumped 2 simulated seconds — proof the check reads the injected clock, not a disguised sleep. | `rpc/capture_data_source.hpp`, `rpc/dispatcher.{hpp,cpp}`, `store/rules.{hpp,cpp}`, `store/categories.{hpp,cpp}`, `store/tasks.{hpp,cpp}` | — | done |
|
||||
| ~~D8~~ | **Closed alongside D7** — `capture.getRules` returns the same settings-backed `enabled`/`monitoredExtensions`/`monitoredMimeTypes`/`minSizeBytes`/`excludedHosts`/`bypassModifier` capture.offer itself reads, so the two can never drift. `rulesVersion` is a constant `1` — there is no persisted revision counter yet (nothing writes `rules.*` outside this process's own lifetime to need one across a restart), and the extension already re-fetches on `event.settings.changed` regardless of what this number does; noted in case a real counter becomes worth adding later. | `rpc/dispatcher.cpp` | `rulesVersion` is a placeholder constant | — |
|
||||
| D1 | Pairing prompt is `EnvAutoApprover` (needs `VELOX_PAIR_AUTO=1`) | `rpc/pairing.hpp`, `main.cpp` | A GUI dialog / `org.freedesktop.Notifications` approver is integration work | Build step 7 (systemd + notifications) |
|
||||
| D1 | Pairing prompt is `EnvAutoApprover` (needs `VELOX_PAIR_AUTO=1`) | `rpc/pairing.hpp`, `main.cpp` | A GUI dialog / `org.freedesktop.Notifications` approver is integration work | the `org.freedesktop.Notifications` half of build step 7 — the systemd half closed as D11 below |
|
||||
| — | **D1, checked this pass, not attempted:** `libdbus-1-dev` (or `libsystemd-dev` for `sd-bus`) has no headers installed in this build environment — only the runtime `.so`s (`dpkg -l`/`apt-cache policy` confirm `libdbus-1-3` present, `libdbus-1-dev` not, "Candidate" available but not installed). A real notification-backed approver needs one of those linked into `veloxd`, which is a new build dependency for `daemon/CMakeLists.txt` (`find_package`/`pkg_check_modules`) and — since packaging manifests need to know about it too — arguably a decision to surface rather than something to reach for silently mid-session. `PairingApprover::approve()` is also still synchronous by shape (its own doc comment already says so: "the real notification-backed approver will run async and is not this shape") — swapping it for the async pattern this session built for `download.probe` (`rpc::TaskActionPort` + the server-layer deferred-reply special-case) is the right shape once there's a real implementation to justify the churn; reshaping the interface with nothing behind it yet would just be churn. Left `EnvAutoApprover` in place rather than build a fragile hand-rolled D-Bus wire client to avoid the missing headers — a broken pairing approver is worse than an honest stub. | `rpc/pairing.hpp` | missing dev headers + an undiscussed new dependency | once `libdbus-1-dev`/`libsystemd-dev` is available and the dependency is approved |
|
||||
| ~~D2~~ | **Closed** — `download.probe` is real on both transports. It's genuinely async (the engine's probe pool, up to the schema's 30s `x-deadlineMs`) and so cannot fit `VeloxDispatcher::on_download_probe`'s synchronous `HandlerResult<T>` return — `uds_server.cpp`/`ws_server.cpp` special-case `"download.probe"` before the generic `dispatch()`, exactly the way they already special-case `session.hello`/`session.subscribe`, and queue the reply whenever the callback fires. `rpc::TaskActionPort::probe_now` (kept in proto/std terms, no `vdm::net::*`, so `veloxd_rpc` never needs `core/include`'s vdm headers) is what both transports call; `sched::Scheduler::probe_now` is the implementation — builds a `vdm::net::ProbeRequest`, runs it on the engine's probe pool, maps a failure to `-32013 ProbeFailed` (with `data.httpStatus` when there was one), and fills `suggestedCategoryId`/`suggestedSaveDir` with a plain extension match against the categories table (not the real rules engine — that's still D3). Verified live: a real probe answers in ~5ms; a bad host maps to `-32013`; a connection issuing a 10s `slow-loris` probe does not block a second connection's `download.list` (answered in ~1ms) — confirms the async design actually keeps the loop free, not just compiles. | `rpc/task_action_port.hpp`, `rpc/{uds_server,ws_server}.{hpp,cpp}`, `sched/scheduler.{cpp,hpp}` | — | done |
|
||||
| D3 | Stub handlers for the rest: `grabber.*`, `media.*` | `rpc/dispatcher.cpp` | HLS/DASH grabber and media-variant support don't exist anywhere in this build yet — a bigger feature than a store-wiring pass | M4 territory, per AGENT-DAEMON.md |
|
||||
@@ -26,4 +26,5 @@ close. Kept here (not buried in commit messages) so the next pass can see them a
|
||||
| ~~D4b~~ | **Closed** — `download.pause`/`resume`/`start`/`cancel` and `queue.start`/`stop` all drive the scheduler now, and apply *immediately* (not deferred to the next tick — pausing/resuming/cancelling a live transfer can't wait up to 1s, and per ADR 0013 §3 the governor never touches a user-owned pause on its own). New `rpc::TaskActionPort` interface (owned by `rpc/`, implemented by `sched::Scheduler`) is the seam dispatcher.hpp depends on instead of `sched/scheduler.hpp` directly — avoids a real `veloxd_rpc` <-> `veloxd_sched` circular library dependency (`veloxd_sched` already links `veloxd_rpc` for `EventHub`). `Scheduler::user_pause/resume/start/cancel` + `pause_queue` engine-call-then-eager-transition, matching `tick()`'s existing `to_pause` pattern. Fixed a real bug hit while building this: `transition()` always overwrote `pause_reason` to NULL when the engine's own delayed pause-ack callback arrived with no explicit reason, clobbering whatever the actual initiator (user or governor) had just written — now it preserves the stored reason when none is supplied. Verified against real `veloxd` + `tools/testserver`: pausing a live single-segment throttled transfer freezes `downloadedBytes`, resume continues it from that point, cancel stops it; `queue.stop(pauseRunning:true)` pauses the queue's running task immediately. NOTE: `download.start`'s contract "a task in 'queued' jumps its queue" (priority bump) is not implemented — admission is still plain FIFO by `created_at`. | `sched/scheduler.{cpp,hpp}`, `rpc/task_action_port.hpp`, `rpc/dispatcher.{hpp,cpp}`, `store/queues.{cpp,hpp}` | — | done, except the queue-jump priority bump noted above |
|
||||
| ~~D5~~ | **Mostly closed** — `rpc/event_hub` fans out per-subscription; `session.subscribe` on both transports registers/updates/tears down a real subscription; `Scheduler::transition()` publishes `event.task.state` (with `previousState`) on every state change, scheduler-driven or engine-reported; `dispatcher::on_download_add` publishes `event.task.added`; a 250 ms timer batches `Scheduler::progress_snapshot()` into one `event.task.progress` array per AGENT-DAEMON.md item 5 / the schema's `x-maxRateHz: 4`. Verified live end to end. | — | `event.task.removed` has no source yet (`download.remove` is D3); `event.speed.global`, `event.notify`, `event.auth.required`, `event.settings.changed`, `event.grabber.progress` are unpublished — each lands with its owning handler | as each owning D3 handler lands |
|
||||
| ~~D6~~ | **Closed** — engine numbers now reach the store: `Scheduler::tick()` probes (`EnginePort::probe`) before every `start()`, persisting `sizeBytes`/`resumable`/validators via `Tasks::set_probe_result` before a byte moves; `Scheduler::persist_progress()` (called from `progress_snapshot()` *and* once more from `on_engine_state` right before `release()`/unmap on every terminal transition) writes `downloadedBytes`/`speedBps`/`segments`/`segmentDetail` from the engine's `Progress`, so a task that finishes between two 250 ms ticks (the common case for anything small or fast) still leaves real numbers instead of the pre-persistence defaults. `TaskSummary.segments` is sourced from `segments.size()` when the task has any (matching what actually lands in `segmentDetail`, per the schema's "exactly `segments` entries"), falling back to the engine's `effective_segments` (budget slots *held*, not necessarily physical range count — see `core/include/vdm/task/download.hpp`'s `Progress` comment) only pre-segmentation. `Tasks::set_final_bytes` tops up `on_finished`'s byte count as a last-resort backstop. Migration `0002` adds `speed_bps` to both `tasks` and `segments`, and fixes `segments.state`'s CHECK to include `'pending'` (0001 omitted it, so a pre-connect snapshot could never be written). Verified against real `veloxd` + `tools/testserver` (not just unit tests): `download.list`/`download.get` correct immediately after completion and after a daemon restart. | `sched/scheduler.{cpp,hpp}`, `store/{tasks,segments}.{cpp,hpp}`, `store/migrations/0002_*.sql` | — | done |
|
||||
| ~~D11~~ | **Closed — build order items 7 (the systemd half) and 9: `velox-nmhost`, socket activation, the systemd user units, and `velox(1)`.** `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, exits the moment either side closes. Deliberately dependency-free (no `veloxd_*` library, no `nlohmann_json`) since it runs unconfined outside Firefox's sandbox whatever the packaging format. Two real bugs found and fixed while getting the integration test to actually pass rather than hang: (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/writability were never both observable through the same `pollfd` entry. Both are exactly the class of bug a "trivial pump" invites and unit tests over the real binary (not just its helper functions) exist specifically to catch. `packaging/nativehost/com.velox.host.json` + its own `README.md` supersede `AGENT-DAEMON.md`'s stale "four locations" (spike S1 / ADR 0003 found only three are real — the fourth, `~/snap/firefox/common/.mozilla/...`, is not read by snap Firefox at all) and spell out the per-user-manifest / postinst implication for PKG/QA. `EnginePort`-style: `rpc/systemd_activation.cpp` is a from-scratch `sd_listen_fds()` (env vars only, no `libsystemd` link — `LISTEN_PID`/`LISTEN_FDS`, fd 3) 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 three-tier `queue`/`settings` subcommands `AGENT-DAEMON.md` build step 8 originally sketched are not implemented in `cli/src/main.cpp` yet, so the page doesn't claim they are) — checked warning-free with `groff -mandoc -ww -z`. | `nmhost/{CMakeLists.txt,src/main.cpp,tests/}`, `daemon/src/rpc/{systemd_activation.{hpp,cpp},uds_server.cpp}`, `packaging/{nativehost,systemd}/`, `cli/man/velox.1` | — | done |
|
||||
| — | ~~Observed, not fixed (CORE, not this lane)~~ — **routed to CORE by the user.** `vdm::task::Progress.speed_bps` reads back as `0` for the whole lifetime of a live, real (non-fake) throttled download, despite `downloadedBytes` visibly advancing between polls — `core/src/task/download_task.cpp`'s per-worker EWMA never seems to produce a nonzero aggregate in this build. DAEMON passes `EnginePort::progress()`'s `speed_bps` straight through (`Scheduler::persist_progress`); nothing in this lane drops it. Still reproduces in the D4b live checks above (0 throughout a paused/resumed/cancelled transfer whose `downloadedBytes` visibly moved) — not re-filed, since it's already CORE's. |
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
#include "rpc/systemd_activation.hpp"
|
||||
|
||||
#include <unistd.h>
|
||||
|
||||
#include <cstdlib>
|
||||
#include <string>
|
||||
|
||||
namespace velox::daemon::rpc {
|
||||
|
||||
namespace {
|
||||
constexpr int kListenFdsStart = 3; // SD_LISTEN_FDS_START
|
||||
} // namespace
|
||||
|
||||
int systemd_activated_fd() {
|
||||
const char* pid_env = std::getenv("LISTEN_PID");
|
||||
const char* fds_env = std::getenv("LISTEN_FDS");
|
||||
int fd = -1;
|
||||
|
||||
if (pid_env != nullptr && fds_env != nullptr) {
|
||||
try {
|
||||
if (std::stol(pid_env) == static_cast<long>(::getpid()) && std::stol(fds_env) == 1) {
|
||||
fd = kListenFdsStart;
|
||||
}
|
||||
} catch (...) {
|
||||
// Malformed env from something other than systemd; treat as not activated.
|
||||
}
|
||||
}
|
||||
|
||||
// Contract: consumed once, then cleared, so a value meant for veloxd is never
|
||||
// mistaken for one meant for a process it might itself exec later.
|
||||
::unsetenv("LISTEN_PID");
|
||||
::unsetenv("LISTEN_FDS");
|
||||
::unsetenv("LISTEN_FDNAMES");
|
||||
return fd;
|
||||
}
|
||||
|
||||
} // namespace velox::daemon::rpc
|
||||
@@ -0,0 +1,20 @@
|
||||
#pragma once
|
||||
|
||||
// Minimal sd_listen_fds(3) reimplementation — one function, no libsystemd dependency, for
|
||||
// the one fd velox.socket ever hands us. See velox.socket / velox.service in
|
||||
// packaging/nativehost's systemd unit pair: the socket unit binds
|
||||
// $XDG_RUNTIME_DIR/velox/velox.sock itself (before veloxd ever runs, so the very first
|
||||
// connection attempt after boot is queued by the kernel rather than refused) and execs
|
||||
// veloxd with that listening fd already open at fd 3, LISTEN_FDS=1, LISTEN_PID=<our pid>.
|
||||
|
||||
namespace velox::daemon::rpc {
|
||||
|
||||
// The systemd-activated listening socket fd, or -1 if this process was not socket-
|
||||
// activated (LISTEN_PID doesn't match our pid, or LISTEN_FDS is unset/not exactly 1 — more
|
||||
// than one would mean a unit file mismatch, since veloxd only ever asks for one socket).
|
||||
// Clears LISTEN_PID/LISTEN_FDS from the environment on the way out either way, per
|
||||
// sd_listen_fds's own contract, so a value meant for us is never mistaken for one meant for
|
||||
// a process veloxd might itself exec later.
|
||||
int systemd_activated_fd();
|
||||
|
||||
} // namespace velox::daemon::rpc
|
||||
@@ -12,7 +12,10 @@
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <fcntl.h>
|
||||
|
||||
#include "rpc/event_loop.hpp"
|
||||
#include "rpc/systemd_activation.hpp"
|
||||
#include "version.hpp"
|
||||
|
||||
namespace velox::daemon::rpc {
|
||||
@@ -74,6 +77,20 @@ UdsServer::~UdsServer() {
|
||||
}
|
||||
|
||||
std::error_code UdsServer::start() {
|
||||
// velox.socket (systemd user unit, socket activation): the unit binds this path itself
|
||||
// before veloxd ever runs and hands the already-listening fd over at fd 3 — the first
|
||||
// connection after boot is queued by the kernel rather than refused, and there is no
|
||||
// window where a client sees ECONNREFUSED while the daemon is still starting. Skips
|
||||
// create/bind/chmod/listen entirely; the socket file's lifecycle (including removal on
|
||||
// stop) belongs to the unit, not to us, so bound_ stays false.
|
||||
if (const int activated = systemd_activated_fd(); activated >= 0) {
|
||||
::fcntl(activated, F_SETFL, O_NONBLOCK);
|
||||
::fcntl(activated, F_SETFD, FD_CLOEXEC);
|
||||
listen_fd_ = activated;
|
||||
loop_.add_fd(listen_fd_, kRead, [this](int, unsigned) { on_listener_readable(); });
|
||||
return {};
|
||||
}
|
||||
|
||||
if (path_.size() + 1 > sizeof(sockaddr_un::sun_path)) return errc(ENAMETOOLONG);
|
||||
|
||||
const int fd = ::socket(AF_UNIX, SOCK_STREAM | SOCK_NONBLOCK | SOCK_CLOEXEC, 0);
|
||||
|
||||
@@ -24,6 +24,7 @@ veloxd_test(sched_scheduler LIBS veloxd_sched veloxd_rpc)
|
||||
veloxd_test(event_hub LIBS veloxd_rpc)
|
||||
veloxd_test(store_categories_queues LIBS veloxd_store)
|
||||
veloxd_test(single_instance LIBS veloxd_rpc)
|
||||
veloxd_test(systemd_activation LIBS veloxd_rpc)
|
||||
veloxd_test(dispatcher_settings LIBS veloxd_rpc veloxd_store)
|
||||
veloxd_test(capture_offer LIBS veloxd_rpc veloxd_store)
|
||||
veloxd_test(dispatcher_misc LIBS veloxd_rpc veloxd_store)
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
// systemd_activated_fd(): the LISTEN_PID/LISTEN_FDS contract, without a real systemd.
|
||||
|
||||
#include <unistd.h>
|
||||
|
||||
#include <cstdlib>
|
||||
#include <string>
|
||||
|
||||
#include "check.hpp"
|
||||
#include "rpc/systemd_activation.hpp"
|
||||
|
||||
using namespace velox::daemon::rpc;
|
||||
|
||||
namespace {
|
||||
void set_env(const char* k, const std::string& v) { ::setenv(k, v.c_str(), 1); }
|
||||
} // namespace
|
||||
|
||||
void run() {
|
||||
// Not activated: neither var set.
|
||||
::unsetenv("LISTEN_PID");
|
||||
::unsetenv("LISTEN_FDS");
|
||||
CHECK_EQ(systemd_activated_fd(), -1);
|
||||
|
||||
// LISTEN_PID for a different process: not us, so not activated.
|
||||
set_env("LISTEN_PID", std::to_string(::getpid() + 1));
|
||||
set_env("LISTEN_FDS", "1");
|
||||
CHECK_EQ(systemd_activated_fd(), -1);
|
||||
// Consumed regardless of the outcome — a stale value from some other process's
|
||||
// exec chain must not leak into what veloxd checks next time.
|
||||
CHECK(::getenv("LISTEN_PID") == nullptr);
|
||||
CHECK(::getenv("LISTEN_FDS") == nullptr);
|
||||
|
||||
// Our own pid, LISTEN_FDS=1: activated, fd 3 (SD_LISTEN_FDS_START).
|
||||
set_env("LISTEN_PID", std::to_string(::getpid()));
|
||||
set_env("LISTEN_FDS", "1");
|
||||
CHECK_EQ(systemd_activated_fd(), 3);
|
||||
CHECK(::getenv("LISTEN_PID") == nullptr);
|
||||
|
||||
// Our own pid but LISTEN_FDS=2: a unit file mismatch (veloxd only ever asks for one
|
||||
// socket) — refuse rather than guess which of two fds is the right one.
|
||||
set_env("LISTEN_PID", std::to_string(::getpid()));
|
||||
set_env("LISTEN_FDS", "2");
|
||||
CHECK_EQ(systemd_activated_fd(), -1);
|
||||
|
||||
// Garbage LISTEN_FDS: not activated, not a crash.
|
||||
set_env("LISTEN_PID", std::to_string(::getpid()));
|
||||
set_env("LISTEN_FDS", "not-a-number");
|
||||
CHECK_EQ(systemd_activated_fd(), -1);
|
||||
|
||||
::unsetenv("LISTEN_PID");
|
||||
::unsetenv("LISTEN_FDS");
|
||||
}
|
||||
|
||||
TEST_MAIN()
|
||||
@@ -43,7 +43,11 @@ Read `contracts/`, `core/include/`, `docs/`. Never write in `core/`, `gui/`, or
|
||||
output. Build this early: it is how you test the daemon before the GUI exists.
|
||||
9. **`velox-nmhost`** — 4-byte-length-prefixed stdio ⇄ Unix socket pump. **Under 300 lines,
|
||||
zero business logic**, and it must exit cleanly when Firefox closes the pipe. Install
|
||||
manifests to all four locations listed in `docs/05` §4.
|
||||
manifests to the three real locations in `docs/05` §4 / `docs/adr/0003` — not four:
|
||||
spike S1 found `~/snap/firefox/common/.mozilla/native-messaging-hosts/` (the intuitive
|
||||
"inside the snap" path) is not actually read by snap Firefox, and corrected `docs/05` §4
|
||||
down from its original four-location list. `packaging/nativehost/README.md` has the
|
||||
current table.
|
||||
|
||||
## Definition of done (M1)
|
||||
- Passes the full conformance suite as a server, over **both** transports.
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
# nmhost/ — velox-nmhost, the Firefox native-messaging host. Owned by lane DAEMON.
|
||||
#
|
||||
# Deliberately dependency-free: no veloxd_* library, no nlohmann_json, no SQLite. It is a
|
||||
# byte-level pump between two framings (see src/main.cpp's own header comment) and runs
|
||||
# unconfined outside Firefox's sandbox (ADR 0003) — the less it links, the less there is to
|
||||
# go wrong running from wherever a snap/deb/flatpak install puts it.
|
||||
|
||||
add_executable(velox-nmhost src/main.cpp)
|
||||
target_compile_features(velox-nmhost PRIVATE cxx_std_23)
|
||||
target_compile_options(velox-nmhost PRIVATE -Wall -Wextra -Wpedantic -Werror)
|
||||
|
||||
if(VELOX_BUILD_TESTS AND EXISTS ${CMAKE_CURRENT_SOURCE_DIR}/tests/CMakeLists.txt)
|
||||
add_subdirectory(tests)
|
||||
endif()
|
||||
@@ -0,0 +1,203 @@
|
||||
// velox-nmhost — Firefox native-messaging host. A dumb pump between two framings, nothing
|
||||
// else: stdin/stdout speak Firefox's own protocol (a 4-byte native-byte-order length
|
||||
// prefix, then that many bytes of UTF-8 JSON); $XDG_RUNTIME_DIR/velox/velox.sock speaks
|
||||
// veloxd's own NDJSON (one '\n'-terminated JSON value per line, daemon/src/rpc/ndjson.hpp).
|
||||
// Reframing between the two is the entire job.
|
||||
//
|
||||
// Runs unconfined outside Firefox's snap sandbox with the real $HOME and
|
||||
// $XDG_RUNTIME_DIR (ADR 0003 §Q2) — see packaging/nativehost/ for the manifest this is
|
||||
// installed as, and which native-messaging-hosts directory actually gets read by which
|
||||
// Firefox flavour.
|
||||
//
|
||||
// No business logic: no JSON parsing (frames are pure byte spans; only the length prefix
|
||||
// and the line boundary matter here), no retry/backoff (the extension re-launches a fresh
|
||||
// host on its own reconnect), no protocol version check (veloxd and the extension settle
|
||||
// that between themselves once the pump hands their bytes through). Exits the moment
|
||||
// either side closes.
|
||||
|
||||
#include <fcntl.h>
|
||||
#include <sys/socket.h>
|
||||
#include <sys/un.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <cerrno>
|
||||
#include <cstdint>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <poll.h>
|
||||
#include <string>
|
||||
|
||||
namespace {
|
||||
|
||||
// Firefox's own cap (host -> browser) is 1 MiB; this is just a sanity backstop against a
|
||||
// runaway peer so a malformed stream can't grow a buffer without bound.
|
||||
constexpr std::size_t kMaxFrameBytes = 8 * 1024 * 1024;
|
||||
|
||||
std::string socket_path() {
|
||||
const char* xdg = std::getenv("XDG_RUNTIME_DIR");
|
||||
std::string base = (xdg != nullptr && xdg[0] != '\0') ? xdg
|
||||
: ("/run/user/" + std::to_string(::getuid()));
|
||||
if (!base.empty() && base.back() == '/') base.pop_back();
|
||||
return base + "/velox/velox.sock";
|
||||
}
|
||||
|
||||
int connect_socket(const std::string& path) {
|
||||
const int fd = ::socket(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0);
|
||||
if (fd < 0) return -1;
|
||||
sockaddr_un addr{};
|
||||
addr.sun_family = AF_UNIX;
|
||||
if (path.size() + 1 > sizeof(addr.sun_path)) {
|
||||
::close(fd);
|
||||
return -1;
|
||||
}
|
||||
std::memcpy(addr.sun_path, path.c_str(), path.size());
|
||||
if (::connect(fd, reinterpret_cast<sockaddr*>(&addr), sizeof(addr)) != 0) {
|
||||
::close(fd);
|
||||
return -1;
|
||||
}
|
||||
return fd;
|
||||
}
|
||||
|
||||
// Reads everything currently available into `buf`. true on EOF, false otherwise (short
|
||||
// reads / EAGAIN just return with whatever was appended).
|
||||
bool read_available(int fd, std::string& buf) {
|
||||
char chunk[65536];
|
||||
for (;;) {
|
||||
const ssize_t n = ::read(fd, chunk, sizeof(chunk));
|
||||
if (n > 0) {
|
||||
buf.append(chunk, static_cast<std::size_t>(n));
|
||||
continue;
|
||||
}
|
||||
if (n == 0) return true; // EOF
|
||||
if (errno == EAGAIN || errno == EWOULDBLOCK) return false;
|
||||
if (errno == EINTR) continue;
|
||||
return true; // treat any other error as if the peer hung up
|
||||
}
|
||||
}
|
||||
|
||||
// Writes as much of `buf` as the fd accepts right now, trimming what was sent. Returns
|
||||
// false on a hard error (peer gone); EAGAIN is not an error, just "try again later".
|
||||
bool flush_some(int fd, std::string& buf) {
|
||||
while (!buf.empty()) {
|
||||
const ssize_t n = ::write(fd, buf.data(), buf.size());
|
||||
if (n > 0) {
|
||||
buf.erase(0, static_cast<std::size_t>(n));
|
||||
continue;
|
||||
}
|
||||
if (n < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) return true;
|
||||
if (n < 0 && errno == EINTR) continue;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// stdin frames (4-byte length + payload) -> NDJSON lines appended to `to_socket`.
|
||||
// Malformed (oversized) length is a hard stop.
|
||||
bool drain_stdin_frames(std::string& in, std::string& to_socket) {
|
||||
for (;;) {
|
||||
if (in.size() < 4) return true;
|
||||
std::uint32_t len;
|
||||
std::memcpy(&len, in.data(), 4);
|
||||
if (len > kMaxFrameBytes) return false;
|
||||
if (in.size() < 4 + len) return true;
|
||||
to_socket.append(in, 4, len);
|
||||
to_socket.push_back('\n');
|
||||
in.erase(0, 4 + len);
|
||||
}
|
||||
}
|
||||
|
||||
// NDJSON lines from the socket -> stdout frames (4-byte length + payload) appended to
|
||||
// `to_stdout`.
|
||||
bool drain_socket_lines(std::string& in, std::string& to_stdout) {
|
||||
for (;;) {
|
||||
const auto nl = in.find('\n');
|
||||
if (nl == std::string::npos) {
|
||||
if (in.size() > kMaxFrameBytes) return false;
|
||||
return true;
|
||||
}
|
||||
std::string_view line(in.data(), nl);
|
||||
if (!line.empty() && line.back() == '\r') line.remove_suffix(1);
|
||||
if (line.size() > kMaxFrameBytes) return false;
|
||||
const auto len = static_cast<std::uint32_t>(line.size());
|
||||
to_stdout.append(reinterpret_cast<const char*>(&len), 4);
|
||||
to_stdout.append(line);
|
||||
in.erase(0, nl + 1);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
const int sock = connect_socket(socket_path());
|
||||
if (sock < 0) return 1; // daemon not running / socket missing: nothing to pump
|
||||
|
||||
// Every fd this pumps must be non-blocking: read_available()'s own drain loop keeps
|
||||
// calling read() until it actually sees EAGAIN, and a blocking fd never returns that —
|
||||
// it just blocks inside the "drain what's available" loop instead of going back to
|
||||
// poll(), which stalls the whole pump the moment one side has more to send than fits
|
||||
// in a single read().
|
||||
::fcntl(0, F_SETFL, ::fcntl(0, F_GETFL) | O_NONBLOCK);
|
||||
::fcntl(1, F_SETFL, ::fcntl(1, F_GETFL) | O_NONBLOCK);
|
||||
::fcntl(sock, F_SETFL, ::fcntl(sock, F_GETFL) | O_NONBLOCK);
|
||||
|
||||
std::string stdin_buf, stdout_buf, socket_in_buf, socket_out_buf;
|
||||
bool stdin_eof = false;
|
||||
|
||||
// Three distinct descriptors, not two: stdin (0) and stdout (1) are separate pipes
|
||||
// (read-only and write-only respectively — never the same fd, even though they sit
|
||||
// next to each other in a shell's mental model of "the process's stdio"), plus the
|
||||
// bidirectional socket.
|
||||
enum { kStdin, kStdout, kSock };
|
||||
for (;;) {
|
||||
pollfd fds[3] = {
|
||||
{0, 0, 0},
|
||||
{1, 0, 0},
|
||||
{sock, 0, 0},
|
||||
};
|
||||
if (!stdin_eof) fds[kStdin].events |= POLLIN;
|
||||
if (!stdout_buf.empty()) fds[kStdout].events |= POLLOUT;
|
||||
fds[kSock].events |= POLLIN;
|
||||
if (!socket_out_buf.empty()) fds[kSock].events |= POLLOUT;
|
||||
|
||||
// Nothing left to wait for: both directions exhausted.
|
||||
if (fds[kStdin].events == 0 && fds[kStdout].events == 0 && fds[kSock].events == 0) break;
|
||||
|
||||
const int n = ::poll(fds, 3, -1);
|
||||
if (n < 0) {
|
||||
if (errno == EINTR) continue;
|
||||
break;
|
||||
}
|
||||
|
||||
if (fds[kStdout].revents & POLLOUT) {
|
||||
if (!flush_some(1, stdout_buf)) break; // Firefox closed our stdout
|
||||
}
|
||||
if (fds[kSock].revents & POLLOUT) {
|
||||
if (!flush_some(sock, socket_out_buf)) break;
|
||||
}
|
||||
if (fds[kStdin].revents & (POLLIN | POLLHUP)) {
|
||||
if (read_available(0, stdin_buf)) stdin_eof = true;
|
||||
if (!drain_stdin_frames(stdin_buf, socket_out_buf)) break;
|
||||
}
|
||||
if (fds[kSock].revents & (POLLIN | POLLHUP)) {
|
||||
const bool socket_eof = read_available(sock, socket_in_buf);
|
||||
if (!drain_socket_lines(socket_in_buf, stdout_buf)) break;
|
||||
if (socket_eof) {
|
||||
// The daemon is gone. Flush whatever we already turned into stdout
|
||||
// frames, then stop — there is nothing left to relay either direction.
|
||||
(void)flush_some(1, stdout_buf);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ((fds[kStdin].revents | fds[kStdout].revents | fds[kSock].revents) &
|
||||
(POLLERR | POLLNVAL))
|
||||
break;
|
||||
|
||||
// Firefox closed the pipe: nothing more will ever arrive on stdin, and once our
|
||||
// own outbound backlog drains there is nothing left to send it either. Stop
|
||||
// rather than idle forever relaying replies nobody reads.
|
||||
if (stdin_eof && socket_out_buf.empty()) break;
|
||||
}
|
||||
|
||||
::close(sock);
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
# Integration test only — velox-nmhost has no internal functions worth unit-testing in
|
||||
# isolation (it's ~15 lines of byte-shuffling helpers around one poll() loop); what matters
|
||||
# is the real binary's observable behaviour over real pipes and a real socket.
|
||||
|
||||
add_executable(velox_nmhost_pump_test pump_test.cpp)
|
||||
target_compile_features(velox_nmhost_pump_test PRIVATE cxx_std_23)
|
||||
target_compile_options(velox_nmhost_pump_test PRIVATE -Wall -Wextra -Wpedantic -Werror)
|
||||
target_compile_definitions(velox_nmhost_pump_test PRIVATE
|
||||
VELOX_NMHOST_BIN="$<TARGET_FILE:velox-nmhost>")
|
||||
add_dependencies(velox_nmhost_pump_test velox-nmhost)
|
||||
|
||||
add_test(NAME nmhost.pump COMMAND velox_nmhost_pump_test)
|
||||
set_tests_properties(nmhost.pump PROPERTIES TIMEOUT 30)
|
||||
@@ -0,0 +1,54 @@
|
||||
#pragma once
|
||||
|
||||
// Minimal test harness: CHECK accumulates failures, TEST_MAIN reports and sets the exit
|
||||
// code. Copied from daemon/tests/check.hpp rather than shared across a build-dependency —
|
||||
// nmhost is deliberately dependency-free, tests included.
|
||||
|
||||
#include <cstdio>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace veloxd_test {
|
||||
|
||||
inline std::vector<std::string>& failures() {
|
||||
static std::vector<std::string> f;
|
||||
return f;
|
||||
}
|
||||
inline int& checks() {
|
||||
static int n = 0;
|
||||
return n;
|
||||
}
|
||||
|
||||
} // namespace veloxd_test
|
||||
|
||||
#define CHECK(cond) \
|
||||
do { \
|
||||
++::veloxd_test::checks(); \
|
||||
if (!(cond)) { \
|
||||
::veloxd_test::failures().push_back(std::string(__FILE__) + ":" + \
|
||||
std::to_string(__LINE__) + ": " + #cond); \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
#define CHECK_EQ(a, b) \
|
||||
do { \
|
||||
++::veloxd_test::checks(); \
|
||||
auto _va = (a); \
|
||||
auto _vb = (b); \
|
||||
if (!(_va == _vb)) { \
|
||||
::veloxd_test::failures().push_back(std::string(__FILE__) + ":" + \
|
||||
std::to_string(__LINE__) + ": " + #a + \
|
||||
" == " + #b); \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
#define TEST_MAIN() \
|
||||
int main() { \
|
||||
run(); \
|
||||
for (const auto& f : ::veloxd_test::failures()) std::printf("FAIL %s\n", f.c_str()); \
|
||||
std::printf("%d/%d checks passed\n", \
|
||||
::veloxd_test::checks() - \
|
||||
static_cast<int>(::veloxd_test::failures().size()), \
|
||||
::veloxd_test::checks()); \
|
||||
return ::veloxd_test::failures().empty() ? 0 : 1; \
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
// Integration test for velox-nmhost: spawns the real binary, feeds it a framed stdin
|
||||
// message, answers over a fake Unix socket standing in for veloxd, and checks what comes
|
||||
// back out on stdout — plus that it exits promptly once stdin closes.
|
||||
|
||||
#include <sys/socket.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/un.h>
|
||||
#include <sys/wait.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <ctime>
|
||||
#include <string>
|
||||
|
||||
#include "check.hpp"
|
||||
|
||||
namespace {
|
||||
|
||||
std::string make_temp_dir() {
|
||||
char tmpl[] = "/tmp/velox-nmhost-test-XXXXXX";
|
||||
const char* dir = ::mkdtemp(tmpl);
|
||||
return dir ? dir : "/tmp";
|
||||
}
|
||||
|
||||
// Firefox's own framing: 4-byte native-byte-order length, then that many bytes.
|
||||
std::string frame(const std::string& payload) {
|
||||
std::uint32_t len = static_cast<std::uint32_t>(payload.size());
|
||||
std::string out(reinterpret_cast<char*>(&len), 4);
|
||||
out += payload;
|
||||
return out;
|
||||
}
|
||||
|
||||
// Reads exactly one framed message off `fd`, blocking. Empty on EOF/short read.
|
||||
std::string read_frame(int fd) {
|
||||
std::uint32_t len = 0;
|
||||
std::size_t got = 0;
|
||||
while (got < 4) {
|
||||
const ssize_t n = ::read(fd, reinterpret_cast<char*>(&len) + got, 4 - got);
|
||||
if (n <= 0) return {};
|
||||
got += static_cast<std::size_t>(n);
|
||||
}
|
||||
std::string payload(len, '\0');
|
||||
got = 0;
|
||||
while (got < len) {
|
||||
const ssize_t n = ::read(fd, payload.data() + got, len - got);
|
||||
if (n <= 0) return {};
|
||||
got += static_cast<std::size_t>(n);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
bool write_all(int fd, const std::string& s) {
|
||||
std::size_t off = 0;
|
||||
while (off < s.size()) {
|
||||
const ssize_t n = ::write(fd, s.data() + off, s.size() - off);
|
||||
if (n <= 0) return false;
|
||||
off += static_cast<std::size_t>(n);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void run() {
|
||||
const std::string dir = make_temp_dir();
|
||||
const std::string velox_dir = dir + "/velox";
|
||||
CHECK(::mkdir(velox_dir.c_str(), 0700) == 0);
|
||||
const std::string sock_path = velox_dir + "/velox.sock";
|
||||
|
||||
// A bare listening socket standing in for veloxd.
|
||||
const int listen_fd = ::socket(AF_UNIX, SOCK_STREAM, 0);
|
||||
CHECK(listen_fd >= 0);
|
||||
sockaddr_un addr{};
|
||||
addr.sun_family = AF_UNIX;
|
||||
std::memcpy(addr.sun_path, sock_path.c_str(), sock_path.size());
|
||||
CHECK(::bind(listen_fd, reinterpret_cast<sockaddr*>(&addr), sizeof(addr)) == 0);
|
||||
CHECK(::listen(listen_fd, 1) == 0);
|
||||
|
||||
int child_stdin[2]; // [0] read (child), [1] write (parent)
|
||||
int child_stdout[2]; // [0] read (parent), [1] write (child)
|
||||
CHECK(::pipe(child_stdin) == 0);
|
||||
CHECK(::pipe(child_stdout) == 0);
|
||||
|
||||
::setenv("XDG_RUNTIME_DIR", dir.c_str(), 1);
|
||||
|
||||
const pid_t pid = ::fork();
|
||||
CHECK(pid >= 0);
|
||||
if (pid == 0) {
|
||||
::dup2(child_stdin[0], 0);
|
||||
::dup2(child_stdout[1], 1);
|
||||
::close(child_stdin[0]);
|
||||
::close(child_stdin[1]);
|
||||
::close(child_stdout[0]);
|
||||
::close(child_stdout[1]);
|
||||
::close(listen_fd);
|
||||
::execl(VELOX_NMHOST_BIN, "velox-nmhost", nullptr);
|
||||
::_exit(127);
|
||||
}
|
||||
::close(child_stdin[0]);
|
||||
::close(child_stdout[1]);
|
||||
|
||||
// Accept nmhost's connection.
|
||||
const int conn = ::accept(listen_fd, nullptr, nullptr);
|
||||
CHECK(conn >= 0);
|
||||
|
||||
// stdin (framed) -> socket (NDJSON line).
|
||||
CHECK(write_all(child_stdin[1], frame(R"({"hello":1})")));
|
||||
char line[256] = {};
|
||||
ssize_t n = ::read(conn, line, sizeof(line) - 1);
|
||||
CHECK(n > 0);
|
||||
CHECK_EQ(std::string(line, static_cast<std::size_t>(n)), std::string("{\"hello\":1}\n"));
|
||||
|
||||
// socket (NDJSON line) -> stdout (framed).
|
||||
CHECK(write_all(conn, "{\"world\":2}\n"));
|
||||
const std::string got = read_frame(child_stdout[0]);
|
||||
CHECK_EQ(got, std::string(R"({"world":2})"));
|
||||
|
||||
// A second round trip on the same connection, to prove buffering across calls works
|
||||
// (not just "the first message happens to line up with one read()").
|
||||
CHECK(write_all(child_stdin[1], frame(R"({"again":3})")));
|
||||
n = ::read(conn, line, sizeof(line) - 1);
|
||||
CHECK(n > 0);
|
||||
CHECK_EQ(std::string(line, static_cast<std::size_t>(n)), std::string("{\"again\":3}\n"));
|
||||
|
||||
// Firefox closes the pipe: nmhost must exit promptly rather than hang.
|
||||
::close(child_stdin[1]);
|
||||
int status = 0;
|
||||
pid_t waited = -1;
|
||||
for (int i = 0; i < 50 && waited != pid; ++i) {
|
||||
waited = ::waitpid(pid, &status, WNOHANG);
|
||||
if (waited == pid) break;
|
||||
struct timespec ts{0, 20'000'000}; // 20ms
|
||||
::nanosleep(&ts, nullptr);
|
||||
}
|
||||
CHECK_EQ(waited, pid);
|
||||
if (waited == pid) CHECK(WIFEXITED(status));
|
||||
|
||||
::close(conn);
|
||||
::close(child_stdout[0]);
|
||||
::close(listen_fd);
|
||||
::unlink(sock_path.c_str());
|
||||
::rmdir(velox_dir.c_str());
|
||||
::rmdir(dir.c_str());
|
||||
}
|
||||
|
||||
TEST_MAIN()
|
||||
@@ -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": ["velox@velox.download"]
|
||||
}
|
||||
```
|
||||
|
||||
- `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