daemon: build velox-nmhost, systemd socket activation + units, velox(1) man page
Closes build order items 7 (the systemd half) and 9 (D11 in deferrals.md). velox-nmhost (nmhost/src/main.cpp, 185 lines): a poll()-driven byte pump between Firefox's native-messaging framing on stdio (4-byte native-byte-order length prefix) and veloxd's own NDJSON framing on the Unix socket. Reframes each direction, no JSON parsing, no retry/backoff (the extension relaunches a fresh host on its own reconnect), exits the moment either side closes. Deliberately dependency-free — no veloxd_* library, no nlohmann_json — since it runs unconfined outside Firefox's sandbox regardless of packaging format. Two real bugs found and fixed while getting the integration test to actually pass rather than hang, both exactly the class of bug a "trivial pump" invites: 1. Never set the pumped fds non-blocking, so the "drain what's available" read loop blocked on its own second read() instead of returning to poll(). 2. stdin and stdout are two different descriptors (0 and 1), not one — an early draft polled POLLOUT on fd 0, which is opened read-only, so EOF and writability were never both observable through the same pollfd entry. packaging/nativehost/com.velox.host.json + its own README.md supersede AGENT-DAEMON.md's stale "four locations" line: spike S1 / ADR 0003 found only three manifest locations are real (~/.mozilla/native-messaging-hosts/ for BOTH deb/tarball and snap Firefox, /usr/lib/mozilla/... for deb/tarball only, the flatpak sandbox path) — the fourth, ~/snap/firefox/common/.mozilla/..., is not read by snap Firefox at all. The README spells out the per-user-manifest / postinst enumeration implication for PKG/QA (postinst runs once as root; the two ~/-relative locations are per-user) and flags that docs/07-packaging.md's own install-layout line only shows the one root-owned path. Socket activation: rpc/systemd_activation.cpp is a from-scratch sd_listen_fds() (env vars only — LISTEN_PID/LISTEN_FDS, fd 3 — no libsystemd link) that UdsServer::start() checks first, skipping its own create/bind/chmod/listen when systemd already bound the socket. packaging/systemd/velox.socket + velox.service are the unit pair, verified both by systemd-analyze verify and by an actual fork/dup2/execve simulation of the activation handshake — a real session.hello round-tripped over the handed-off fd with no bind() ever called inside the daemon for that run. velox.service deliberately skips ProtectSystem=/ProtectHome=/ReadWritePaths=: saveTo.allowedRoots is user-configurable to anywhere on the filesystem, and a sandbox here would turn a legitimately-configured save location into an opaque EROFS/EACCES instead of the daemon's own clear -32011. cli/man/velox.1 documents the CLI as it actually exists today (add/ls/pause/resume/rm, --json) — the queue/settings subcommands AGENT-DAEMON.md's build order originally sketched aren't implemented in cli/src/main.cpp yet, so the page doesn't claim they are. Checked warning-free with groff -mandoc -ww -z. Full ctest: 57/57 (excluding the pre-existing, unrelated conformance failure noted in earlier commits). Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01GRDjHGgpYmMoPE2UFbe7pP
This commit is contained in:
@@ -0,0 +1,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;
|
||||
}
|
||||
Reference in New Issue
Block a user