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:
2026-09-12 22:46:52 +04:00
co-authored by Claude Sonnet 5
parent 4f6c0cc9d2
commit eb72aa522c
21 changed files with 932 additions and 2 deletions
+14
View File
@@ -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()
View File
+203
View File
@@ -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;
}
+13
View File
@@ -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)
+54
View File
@@ -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; \
}
+148
View File
@@ -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()