diff --git a/cli/man/velox.1 b/cli/man/velox.1 new file mode 100644 index 0000000..7c42b9a --- /dev/null +++ b/cli/man/velox.1 @@ -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/ , +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. diff --git a/daemon/CMakeLists.txt b/daemon/CMakeLists.txt index cec053a..a42e211 100644 --- a/daemon/CMakeLists.txt +++ b/daemon/CMakeLists.txt @@ -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 diff --git a/daemon/docs/deferrals.md b/daemon/docs/deferrals.md index 4e54da2..6b2ca6e 100644 --- a/daemon/docs/deferrals.md +++ b/daemon/docs/deferrals.md @@ -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` 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. | diff --git a/daemon/src/rpc/systemd_activation.cpp b/daemon/src/rpc/systemd_activation.cpp new file mode 100644 index 0000000..91fbc94 --- /dev/null +++ b/daemon/src/rpc/systemd_activation.cpp @@ -0,0 +1,37 @@ +#include "rpc/systemd_activation.hpp" + +#include + +#include +#include + +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(::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 diff --git a/daemon/src/rpc/systemd_activation.hpp b/daemon/src/rpc/systemd_activation.hpp new file mode 100644 index 0000000..b19f686 --- /dev/null +++ b/daemon/src/rpc/systemd_activation.hpp @@ -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=. + +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 diff --git a/daemon/src/rpc/uds_server.cpp b/daemon/src/rpc/uds_server.cpp index 2362ad5..6b5bcb1 100644 --- a/daemon/src/rpc/uds_server.cpp +++ b/daemon/src/rpc/uds_server.cpp @@ -12,7 +12,10 @@ #include +#include + #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); diff --git a/daemon/tests/CMakeLists.txt b/daemon/tests/CMakeLists.txt index 17ece28..d5dee9a 100644 --- a/daemon/tests/CMakeLists.txt +++ b/daemon/tests/CMakeLists.txt @@ -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) diff --git a/daemon/tests/systemd_activation_test.cpp b/daemon/tests/systemd_activation_test.cpp new file mode 100644 index 0000000..b1b9e5b --- /dev/null +++ b/daemon/tests/systemd_activation_test.cpp @@ -0,0 +1,53 @@ +// systemd_activated_fd(): the LISTEN_PID/LISTEN_FDS contract, without a real systemd. + +#include + +#include +#include + +#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() diff --git a/docs/agents/AGENT-DAEMON.md b/docs/agents/AGENT-DAEMON.md index c3f8373..b7a0c57 100644 --- a/docs/agents/AGENT-DAEMON.md +++ b/docs/agents/AGENT-DAEMON.md @@ -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. diff --git a/nmhost/CMakeLists.txt b/nmhost/CMakeLists.txt new file mode 100644 index 0000000..94d19c2 --- /dev/null +++ b/nmhost/CMakeLists.txt @@ -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() diff --git a/nmhost/src/.gitkeep b/nmhost/src/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/nmhost/src/main.cpp b/nmhost/src/main.cpp new file mode 100644 index 0000000..04df3a7 --- /dev/null +++ b/nmhost/src/main.cpp @@ -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 +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +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(&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(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(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(line.size()); + to_stdout.append(reinterpret_cast(&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; +} diff --git a/nmhost/tests/CMakeLists.txt b/nmhost/tests/CMakeLists.txt new file mode 100644 index 0000000..41e5d93 --- /dev/null +++ b/nmhost/tests/CMakeLists.txt @@ -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="$") +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) diff --git a/nmhost/tests/check.hpp b/nmhost/tests/check.hpp new file mode 100644 index 0000000..ec1b482 --- /dev/null +++ b/nmhost/tests/check.hpp @@ -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 +#include +#include + +namespace veloxd_test { + +inline std::vector& failures() { + static std::vector 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(::veloxd_test::failures().size()), \ + ::veloxd_test::checks()); \ + return ::veloxd_test::failures().empty() ? 0 : 1; \ + } diff --git a/nmhost/tests/pump_test.cpp b/nmhost/tests/pump_test.cpp new file mode 100644 index 0000000..b3db0d8 --- /dev/null +++ b/nmhost/tests/pump_test.cpp @@ -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 +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +#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(payload.size()); + std::string out(reinterpret_cast(&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(&len) + got, 4 - got); + if (n <= 0) return {}; + got += static_cast(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(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(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(&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(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(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() diff --git a/packaging/nativehost/.gitkeep b/packaging/nativehost/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/packaging/nativehost/README.md b/packaging/nativehost/README.md new file mode 100644 index 0000000..c26f6a4 --- /dev/null +++ b/packaging/nativehost/README.md @@ -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 (`velox@velox.download`). 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. diff --git a/packaging/nativehost/com.velox.host.json b/packaging/nativehost/com.velox.host.json new file mode 100644 index 0000000..8674720 --- /dev/null +++ b/packaging/nativehost/com.velox.host.json @@ -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": ["velox@velox.download"] +} diff --git a/packaging/systemd/README.md b/packaging/systemd/README.md new file mode 100644 index 0000000..0771a02 --- /dev/null +++ b/packaging/systemd/README.md @@ -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=` 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. diff --git a/packaging/systemd/velox.service b/packaging/systemd/velox.service new file mode 100644 index 0000000..ac2be64 --- /dev/null +++ b/packaging/systemd/velox.service @@ -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 diff --git a/packaging/systemd/velox.socket b/packaging/systemd/velox.socket new file mode 100644 index 0000000..3cd5831 --- /dev/null +++ b/packaging/systemd/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