From e60d6669d8959a9b6769ee35fb0e38ccfb655eeb Mon Sep 17 00:00:00 2001 From: sami Date: Thu, 10 Sep 2026 14:56:57 +0400 Subject: [PATCH 1/7] =?UTF-8?q?daemon:=20rpc/=20=E2=80=94=20Unix-socket=20?= =?UTF-8?q?transport=20+=20generated=20dispatch=20wiring=20(build=20step?= =?UTF-8?q?=201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First real code in daemon/. veloxd now listens on $XDG_RUNTIME_DIR/velox/velox.sock (0600, SO_PEERCRED same-UID check), frames NDJSON, and routes every method through the generated velox::proto::dispatch(). The CLI and GUI have a server to talk to. Modules: - rpc/ndjson.hpp — newline-delimited framing, 8 MiB frame cap, CRLF- tolerant, partial-tail buffering. Header-only, tested. - rpc/event_loop — single-threaded poll(2) reactor; never blocks the loop. stop()/wake() are async-signal-safe (eventfd). - rpc/runtime_dir — $XDG_RUNTIME_DIR/velox resolution, 0700, owner-checked; refuses an insecure fallback rather than using /tmp. - rpc/uds_server — listener + non-blocking per-conn read/write with backpressure; handles session.hello (protocol-major check -> -32001, sessionId, transport=uds) and session.subscribe in the server layer; routes the rest through dispatch(). - rpc/dispatcher — VeloxDispatcher : proto::Dispatcher, all 39 methods. download.list answers an empty table; the rest return "not implemented" (-> -32603) until the store lands. - main.cpp — abstract-namespace single-instance lock, signal -> clean shutdown, socket unlinked on exit. Tests (ASan+UBSan and TSan clean): - veloxd.ndjson — framing edge cases - veloxd.uds_roundtrip — real socket: hello ok / version mismatch / empty list / -32601 / -32700 / pipelined requests, and a guard on the -32603 collapse documented in P1. Known gap, filed not worked around: daemon/docs/proto-requests-m1.md P1 — the generated Dispatcher has no error channel below -32603, so handlers cannot yet return -32010/-32011/-32013 with their data payloads. The server layer handles -32001/-32002/-32003 around dispatch(); genuine in-handler errors collapse to -32603 until PROTO gives handlers a real error return. Three error fixtures are non-conformant until then. Not in this drop: rpc/ws_server (next; needs the store for hashed pairing tokens), store/, sched/, cli/. WS reuses this event loop. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Upd9WhG9oppieig5nRDLig --- daemon/CMakeLists.txt | 41 ++++ daemon/docs/proto-requests-m1.md | 79 +++++++ daemon/src/main.cpp | 98 +++++++++ daemon/src/rpc/.gitkeep | 0 daemon/src/rpc/dispatcher.cpp | 188 +++++++++++++++++ daemon/src/rpc/dispatcher.hpp | 103 ++++++++++ daemon/src/rpc/event_loop.cpp | 109 ++++++++++ daemon/src/rpc/event_loop.hpp | 69 +++++++ daemon/src/rpc/ndjson.hpp | 66 ++++++ daemon/src/rpc/runtime_dir.cpp | 56 +++++ daemon/src/rpc/runtime_dir.hpp | 30 +++ daemon/src/rpc/uds_server.cpp | 309 ++++++++++++++++++++++++++++ daemon/src/rpc/uds_server.hpp | 77 +++++++ daemon/src/version.hpp | 12 ++ daemon/tests/CMakeLists.txt | 14 ++ daemon/tests/check.hpp | 53 +++++ daemon/tests/ndjson_test.cpp | 64 ++++++ daemon/tests/uds_roundtrip_test.cpp | 195 ++++++++++++++++++ 18 files changed, 1563 insertions(+) create mode 100644 daemon/CMakeLists.txt create mode 100644 daemon/docs/proto-requests-m1.md create mode 100644 daemon/src/main.cpp delete mode 100644 daemon/src/rpc/.gitkeep create mode 100644 daemon/src/rpc/dispatcher.cpp create mode 100644 daemon/src/rpc/dispatcher.hpp create mode 100644 daemon/src/rpc/event_loop.cpp create mode 100644 daemon/src/rpc/event_loop.hpp create mode 100644 daemon/src/rpc/ndjson.hpp create mode 100644 daemon/src/rpc/runtime_dir.cpp create mode 100644 daemon/src/rpc/runtime_dir.hpp create mode 100644 daemon/src/rpc/uds_server.cpp create mode 100644 daemon/src/rpc/uds_server.hpp create mode 100644 daemon/src/version.hpp create mode 100644 daemon/tests/CMakeLists.txt create mode 100644 daemon/tests/check.hpp create mode 100644 daemon/tests/ndjson_test.cpp create mode 100644 daemon/tests/uds_roundtrip_test.cpp diff --git a/daemon/CMakeLists.txt b/daemon/CMakeLists.txt new file mode 100644 index 0000000..6191528 --- /dev/null +++ b/daemon/CMakeLists.txt @@ -0,0 +1,41 @@ +# daemon/ produces the veloxd binary and the veloxd_rpc static library it is built from. +# Owned by lane DAEMON. Wired in by PKG via add_subdirectory(daemon) in the root file, +# guarded on this file existing. +# +# Layering (CLAUDE.md §3): depends on velox::core and velox::proto. No Qt. The engine +# (velox::core) is not linked yet — it arrives when sched/ and the task glue land; this +# first drop is the RPC transport + dispatcher skeleton so the CLI and GUI have a real +# server to talk to. + +if(NOT TARGET nlohmann_json::nlohmann_json) + find_package(nlohmann_json 3.11 REQUIRED) +endif() +find_package(Threads REQUIRED) + +# --- veloxd_rpc — the server library ---------------------------------------------------- +add_library(veloxd_rpc STATIC + src/rpc/runtime_dir.cpp + src/rpc/event_loop.cpp + src/rpc/uds_server.cpp + src/rpc/dispatcher.cpp +) +add_library(velox::daemon_rpc ALIAS veloxd_rpc) + +target_include_directories(veloxd_rpc + PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/src +) +target_compile_features(veloxd_rpc PUBLIC cxx_std_23) +target_compile_options(veloxd_rpc PRIVATE -Wall -Wextra -Wpedantic -Werror) +target_link_libraries(veloxd_rpc + PUBLIC velox::proto nlohmann_json::nlohmann_json Threads::Threads +) + +# --- veloxd — the daemon binary ------------------------------------------------------- +add_executable(veloxd src/main.cpp) +target_compile_features(veloxd PRIVATE cxx_std_23) +target_compile_options(veloxd PRIVATE -Wall -Wextra -Wpedantic -Werror) +target_link_libraries(veloxd PRIVATE veloxd_rpc) + +if(VELOX_BUILD_TESTS AND EXISTS ${CMAKE_CURRENT_SOURCE_DIR}/tests/CMakeLists.txt) + add_subdirectory(tests) +endif() diff --git a/daemon/docs/proto-requests-m1.md b/daemon/docs/proto-requests-m1.md new file mode 100644 index 0000000..0833155 --- /dev/null +++ b/daemon/docs/proto-requests-m1.md @@ -0,0 +1,79 @@ +# DAEMON → PROTO — requests against `contracts/` (and its codegen) + +Status: **open**. Raised by lane DAEMON while building `rpc/` against `1.3.0`. +PROTO owns `contracts/`, including `contracts/codegen/`. Ranking per +`contracts/README.md` rule 4: a codegen output-shape change that every server must +adopt is effectively **major for the C++ binding** even when the wire is untouched — +it needs a version note and a regen, not a silent change. + +--- + +## P1. The generated `Dispatcher` has no error channel below `-32603` — **blocking a conformant server** + +`velox::proto::Dispatcher`'s 39 methods each return `Result` = +`std::expected`, and `dispatch()` maps **every** handler error to +`ErrorCode::InternalError` (`-32603`): + +```cpp +auto r = handler.on_download_get(*p); +if (!r) + return make_error(id, ErrorCode::InternalError, r.error().message, + nlohmann::json{{"path", r.error().path}}); +``` + +So a handler cannot return any of the contract's own error codes. The error fixtures +in `contracts/fixtures/errors/` that a live server must satisfy (DAEMON DoD: "passes +the full conformance suite as a server, over both transports") include: + +| Fixture | Expected code | `data` | Originates | +|---|---|---|---| +| `download.get.not-found` | `-32010` | `{taskId}` | inside the handler | +| `download.add.invalid-path` | `-32011` | `{path}` | inside the handler (after canonicalization) | +| `download.probe.probe-failed` | `-32013` | `{httpStatus}` | inside the handler | +| `session.pair.rate-limited` | `-32014` | `{retryAfterSec}` | server-side gate, but cleanest expressed as a handler result | +| `session.hello.version-mismatch` | `-32001` | `{expected, actual}` | can be done server-side around `dispatch()` | +| `session.hello.not-paired` | `-32002` | — | server-side WS auth gate, around `dispatch()` | + +`-32001`, `-32002`, `-32003` DAEMON can and will handle in the server layer that wraps +`dispatch()` (`-32003` is already in `dispatch()` itself). But `-32010`, `-32011`, +`-32013` are per-method **handler outcomes** — the daemon knows "no such task" only +after the store lookup, "outside allowed roots" only after `realpath()`. There is no +correct way to surface them today except misreporting as `-32603`, which the TS +conformance replay will reject on the `code` compare. + +**Requested:** give the generated handler methods an error return that carries an +`ErrorCode`, a message, and a free-form `data` object. Shape is PROTO's call; a +minimal one that keeps `ParseError` for the parse path and adds a handler-error type: + +```cpp +struct HandlerError { + ErrorCode code{ErrorCode::InternalError}; + std::string message; + nlohmann::json data{nullptr}; +}; +template using HandlerResult = std::expected; +// Dispatcher::on_* return HandlerResult; dispatch() forwards code/message/data +// straight into make_error() instead of hard-coding InternalError. +``` + +`FixtureDispatcher` and `conformance_main.cpp` would need the trivial follow-on edit +(they only ever return success today, so it is a type-name swap). + +Until this lands, DAEMON's `rpc/` server layer handles `-3200x` around `dispatch()` +where it can, and every genuine in-handler failure collapses to `-32603` with a clear +message — visibly non-conformant on three error fixtures, tracked here, not worked +around by inventing a side channel. + +--- + +## P2. `SessionHelloResult.transport` and `-32001` `data.expected` — minor clarifications + +- `session.hello.version-mismatch`'s `data.expected` is `"1.0.0"` in the fixture, i.e. + the daemon's *current* protocol version string, not a bare major. DAEMON will echo + `kProtocolVersion` (`"1.3.0"`) there unless PROTO wants the fixture's literal + `"1.0.0"` preserved — flag if the conformance compare is exact on that field rather + than structural. +- `SessionHelloResult.transport` is `std::optional` — DAEMON intends to always populate + it (`"uds"` / `"ws"`) so a client knows its privilege level up front, as the field's + own description invites. No change requested; noting the intent so a later "why is + this always set" review has the answer. diff --git a/daemon/src/main.cpp b/daemon/src/main.cpp new file mode 100644 index 0000000..e410538 --- /dev/null +++ b/daemon/src/main.cpp @@ -0,0 +1,98 @@ +// veloxd — the Velox download-manager daemon. +// +// This drop wires up the Unix-socket RPC transport and a dispatcher skeleton so the CLI +// and GUI have a real server to speak to (AGENT-DAEMON.md build order, step 1). The +// WebSocket transport, the SQLite store and the scheduler land next. + +#include +#include +#include +#include +#include + +#include +#include +#include + +#include "rpc/dispatcher.hpp" +#include "rpc/event_loop.hpp" +#include "rpc/runtime_dir.hpp" +#include "rpc/uds_server.hpp" +#include "version.hpp" + +namespace { + +velox::daemon::rpc::EventLoop* g_loop = nullptr; + +void on_signal(int) { + if (g_loop != nullptr) g_loop->stop(); // stop() is async-signal-safe (writes an eventfd) +} + +// Single-instance guard: bind an abstract-namespace Unix socket whose name is unique to +// this user. A second daemon gets EADDRINUSE and exits. The kernel reclaims an +// abstract-namespace address when the holding process dies, so a crash never wedges it +// (docs/01 §2). Returns the held fd (kept open for the process lifetime) or -1. +int acquire_single_instance_lock() { + const int fd = ::socket(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0); + if (fd < 0) return -1; + + const std::string name = std::string("velox-daemon-") + std::to_string(::geteuid()); + sockaddr_un addr{}; + addr.sun_family = AF_UNIX; + // Leading NUL selects the abstract namespace; the name follows, not NUL-terminated. + addr.sun_path[0] = '\0'; + std::memcpy(addr.sun_path + 1, name.c_str(), name.size()); + const socklen_t len = + static_cast(offsetof(sockaddr_un, sun_path) + 1 + name.size()); + + if (::bind(fd, reinterpret_cast(&addr), len) != 0) { + ::close(fd); + return -1; + } + return fd; +} + +} // namespace + +int main() { + std::cout << "veloxd " << velox::daemon::kDaemonVersion << " (protocol " + << velox::proto::kProtocolVersion << ")\n"; + + const int lock_fd = acquire_single_instance_lock(); + if (lock_fd < 0) { + std::cerr << "veloxd: another instance is already running for this user\n"; + return 1; + } + + velox::daemon::rpc::RuntimeDir rt; + if (const auto ec = velox::daemon::rpc::resolve_runtime_dir(rt)) { + std::cerr << "veloxd: cannot prepare runtime directory: " << ec.message() << "\n"; + return 1; + } + + velox::daemon::rpc::EventLoop loop; + g_loop = &loop; + + struct sigaction sa{}; + sa.sa_handler = on_signal; + ::sigemptyset(&sa.sa_mask); + ::sigaction(SIGINT, &sa, nullptr); + ::sigaction(SIGTERM, &sa, nullptr); + ::signal(SIGPIPE, SIG_IGN); // a client vanishing mid-write is EPIPE, never a signal + + velox::daemon::rpc::VeloxDispatcher dispatcher; + velox::daemon::rpc::UdsServer uds(loop, dispatcher, rt.socket_path()); + if (const auto ec = uds.start()) { + std::cerr << "veloxd: cannot listen on " << rt.socket_path() << ": " << ec.message() + << "\n"; + return 1; + } + + std::cout << "veloxd: listening on " << uds.socket_path() << "\n"; + loop.run(); + std::cout << "veloxd: shutting down\n"; + + g_loop = nullptr; + ::close(lock_fd); + return 0; +} diff --git a/daemon/src/rpc/.gitkeep b/daemon/src/rpc/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/daemon/src/rpc/dispatcher.cpp b/daemon/src/rpc/dispatcher.cpp new file mode 100644 index 0000000..4063387 --- /dev/null +++ b/daemon/src/rpc/dispatcher.cpp @@ -0,0 +1,188 @@ +#include "rpc/dispatcher.hpp" + +namespace velox::daemon::rpc { + +namespace proto = velox::proto; + +namespace { + +// The generated Result carries only proto::ParseError, whose {path, message} the +// generated dispatch() forwards as -32603 data. Until P1 (daemon/docs/proto-requests-m1.md) +// gives handlers a real error channel, an unimplemented method says so plainly here. +template +proto::Result not_implemented(const char* method) { + return std::unexpected(proto::ParseError{method, "not implemented in this build"}); +} + +} // namespace + +// --- session.* : handled in the server layer, unreachable here in the running daemon --- +// Kept as explicit stubs so a direct dispatch() caller (a test, a future in-process client) +// gets a clear answer rather than undefined behaviour from a missing override. + +proto::Result +VeloxDispatcher::on_session_hello(const proto::SessionHelloParams&) { + return not_implemented("session.hello"); +} + +proto::Result +VeloxDispatcher::on_session_pair(const proto::SessionPairParams&) { + return not_implemented("session.pair"); +} + +proto::Result +VeloxDispatcher::on_session_subscribe(const proto::SessionSubscribeParams&) { + return not_implemented("session.subscribe"); +} + +// --- download.list : an empty table, so a client can connect and render --------------- + +proto::Result +VeloxDispatcher::on_download_list(const proto::DownloadListParams&) { + proto::DownloadListResult r; + r.total = 0; + return r; +} + +// --- everything else : not implemented until the store and scheduler land ------------- + +proto::Result +VeloxDispatcher::on_capture_getRules(const proto::CaptureGetRulesParams&) { + return not_implemented("capture.getRules"); +} +proto::Result +VeloxDispatcher::on_capture_offer(const proto::CaptureOfferParams&) { + return not_implemented("capture.offer"); +} +proto::Result +VeloxDispatcher::on_category_list(const proto::CategoryListParams&) { + return not_implemented("category.list"); +} +proto::Result +VeloxDispatcher::on_category_remove(const proto::CategoryRemoveParams&) { + return not_implemented("category.remove"); +} +proto::Result +VeloxDispatcher::on_category_upsert(const proto::CategoryUpsertParams&) { + return not_implemented("category.upsert"); +} +proto::Result +VeloxDispatcher::on_download_add(const proto::DownloadSpec&) { + return not_implemented("download.add"); +} +proto::Result +VeloxDispatcher::on_download_addBatch(const proto::DownloadAddBatchParams&) { + return not_implemented("download.addBatch"); +} +proto::Result +VeloxDispatcher::on_download_cancel(const proto::DownloadCancelParams&) { + return not_implemented("download.cancel"); +} +proto::Result +VeloxDispatcher::on_download_get(const proto::DownloadGetParams&) { + return not_implemented("download.get"); +} +proto::Result +VeloxDispatcher::on_download_pause(const proto::DownloadPauseParams&) { + return not_implemented("download.pause"); +} +proto::Result +VeloxDispatcher::on_download_probe(const proto::DownloadProbeParams&) { + return not_implemented("download.probe"); +} +proto::Result +VeloxDispatcher::on_download_provideAuth(const proto::DownloadProvideAuthParams&) { + return not_implemented("download.provideAuth"); +} +proto::Result +VeloxDispatcher::on_download_refreshUrl(const proto::DownloadRefreshUrlParams&) { + return not_implemented("download.refreshUrl"); +} +proto::Result +VeloxDispatcher::on_download_remove(const proto::DownloadRemoveParams&) { + return not_implemented("download.remove"); +} +proto::Result +VeloxDispatcher::on_download_resume(const proto::DownloadResumeParams&) { + return not_implemented("download.resume"); +} +proto::Result +VeloxDispatcher::on_download_start(const proto::DownloadStartParams&) { + return not_implemented("download.start"); +} +proto::Result +VeloxDispatcher::on_download_update(const proto::DownloadUpdateParams&) { + return not_implemented("download.update"); +} +proto::Result +VeloxDispatcher::on_grabber_harvest(const proto::GrabberHarvestParams&) { + return not_implemented("grabber.harvest"); +} +proto::Result +VeloxDispatcher::on_grabber_start(const proto::GrabberStartParams&) { + return not_implemented("grabber.start"); +} +proto::Result +VeloxDispatcher::on_grabber_status(const proto::GrabberStatusParams&) { + return not_implemented("grabber.status"); +} +proto::Result VeloxDispatcher::on_limiter_get(const proto::LimiterGetParams&) { + return not_implemented("limiter.get"); +} +proto::Result VeloxDispatcher::on_limiter_set(const proto::Limiter&) { + return not_implemented("limiter.set"); +} +proto::Result +VeloxDispatcher::on_media_addVariant(const proto::MediaAddVariantParams&) { + return not_implemented("media.addVariant"); +} +proto::Result +VeloxDispatcher::on_media_listVariants(const proto::MediaListVariantsParams&) { + return not_implemented("media.listVariants"); +} +proto::Result +VeloxDispatcher::on_queue_list(const proto::QueueListParams&) { + return not_implemented("queue.list"); +} +proto::Result +VeloxDispatcher::on_queue_reorder(const proto::QueueReorderParams&) { + return not_implemented("queue.reorder"); +} +proto::Result +VeloxDispatcher::on_queue_start(const proto::QueueStartParams&) { + return not_implemented("queue.start"); +} +proto::Result +VeloxDispatcher::on_queue_stop(const proto::QueueStopParams&) { + return not_implemented("queue.stop"); +} +proto::Result +VeloxDispatcher::on_queue_upsert(const proto::QueueUpsertParams&) { + return not_implemented("queue.upsert"); +} +proto::Result +VeloxDispatcher::on_rules_list(const proto::RulesListParams&) { + return not_implemented("rules.list"); +} +proto::Result +VeloxDispatcher::on_rules_upsert(const proto::RulesUpsertParams&) { + return not_implemented("rules.upsert"); +} +proto::Result +VeloxDispatcher::on_schedule_get(const proto::ScheduleGetParams&) { + return not_implemented("schedule.get"); +} +proto::Result +VeloxDispatcher::on_schedule_set(const proto::ScheduleSetParams&) { + return not_implemented("schedule.set"); +} +proto::Result +VeloxDispatcher::on_settings_get(const proto::SettingsGetParams&) { + return not_implemented("settings.get"); +} +proto::Result +VeloxDispatcher::on_settings_set(const proto::SettingsSetParams&) { + return not_implemented("settings.set"); +} + +} // namespace velox::daemon::rpc diff --git a/daemon/src/rpc/dispatcher.hpp b/daemon/src/rpc/dispatcher.hpp new file mode 100644 index 0000000..5e7e97a --- /dev/null +++ b/daemon/src/rpc/dispatcher.hpp @@ -0,0 +1,103 @@ +#pragma once + +// VeloxDispatcher implements the generated velox::proto::Dispatcher — one virtual per RPC +// method. The generated dispatch() does the envelope, the transport check and the param +// parse; a method here only ever sees a validated, typed params struct and returns a +// typed result. +// +// Scope of this drop (AGENT-DAEMON.md build order): the transport is real, the store is +// not. session.hello / session.pair / session.subscribe are handled in the server layer +// (they are connection- and transport-stateful) and never reach this class in the running +// daemon. download.list answers with an empty table so a client can connect and render. +// Every other method returns "not implemented in this build" — which the generated +// dispatch() surfaces as -32603 — until the store and scheduler land. +// +// The -32603 collapse for genuine in-handler errors (-32010 / -32011 / -32013) is a known +// codegen gap, filed as P1 in daemon/docs/proto-requests-m1.md. Not worked around here. + +#include "velox_proto.hpp" + +namespace velox::daemon::rpc { + +class VeloxDispatcher final : public velox::proto::Dispatcher { +public: + velox::proto::Result + on_capture_getRules(const velox::proto::CaptureGetRulesParams&) override; + velox::proto::Result + on_capture_offer(const velox::proto::CaptureOfferParams&) override; + velox::proto::Result + on_category_list(const velox::proto::CategoryListParams&) override; + velox::proto::Result + on_category_remove(const velox::proto::CategoryRemoveParams&) override; + velox::proto::Result + on_category_upsert(const velox::proto::CategoryUpsertParams&) override; + velox::proto::Result + on_download_add(const velox::proto::DownloadSpec&) override; + velox::proto::Result + on_download_addBatch(const velox::proto::DownloadAddBatchParams&) override; + velox::proto::Result + on_download_cancel(const velox::proto::DownloadCancelParams&) override; + velox::proto::Result + on_download_get(const velox::proto::DownloadGetParams&) override; + velox::proto::Result + on_download_list(const velox::proto::DownloadListParams&) override; + velox::proto::Result + on_download_pause(const velox::proto::DownloadPauseParams&) override; + velox::proto::Result + on_download_probe(const velox::proto::DownloadProbeParams&) override; + velox::proto::Result + on_download_provideAuth(const velox::proto::DownloadProvideAuthParams&) override; + velox::proto::Result + on_download_refreshUrl(const velox::proto::DownloadRefreshUrlParams&) override; + velox::proto::Result + on_download_remove(const velox::proto::DownloadRemoveParams&) override; + velox::proto::Result + on_download_resume(const velox::proto::DownloadResumeParams&) override; + velox::proto::Result + on_download_start(const velox::proto::DownloadStartParams&) override; + velox::proto::Result + on_download_update(const velox::proto::DownloadUpdateParams&) override; + velox::proto::Result + on_grabber_harvest(const velox::proto::GrabberHarvestParams&) override; + velox::proto::Result + on_grabber_start(const velox::proto::GrabberStartParams&) override; + velox::proto::Result + on_grabber_status(const velox::proto::GrabberStatusParams&) override; + velox::proto::Result + on_limiter_get(const velox::proto::LimiterGetParams&) override; + velox::proto::Result on_limiter_set(const velox::proto::Limiter&) override; + velox::proto::Result + on_media_addVariant(const velox::proto::MediaAddVariantParams&) override; + velox::proto::Result + on_media_listVariants(const velox::proto::MediaListVariantsParams&) override; + velox::proto::Result + on_queue_list(const velox::proto::QueueListParams&) override; + velox::proto::Result + on_queue_reorder(const velox::proto::QueueReorderParams&) override; + velox::proto::Result + on_queue_start(const velox::proto::QueueStartParams&) override; + velox::proto::Result + on_queue_stop(const velox::proto::QueueStopParams&) override; + velox::proto::Result + on_queue_upsert(const velox::proto::QueueUpsertParams&) override; + velox::proto::Result + on_rules_list(const velox::proto::RulesListParams&) override; + velox::proto::Result + on_rules_upsert(const velox::proto::RulesUpsertParams&) override; + velox::proto::Result + on_schedule_get(const velox::proto::ScheduleGetParams&) override; + velox::proto::Result + on_schedule_set(const velox::proto::ScheduleSetParams&) override; + velox::proto::Result + on_session_hello(const velox::proto::SessionHelloParams&) override; + velox::proto::Result + on_session_pair(const velox::proto::SessionPairParams&) override; + velox::proto::Result + on_session_subscribe(const velox::proto::SessionSubscribeParams&) override; + velox::proto::Result + on_settings_get(const velox::proto::SettingsGetParams&) override; + velox::proto::Result + on_settings_set(const velox::proto::SettingsSetParams&) override; +}; + +} // namespace velox::daemon::rpc diff --git a/daemon/src/rpc/event_loop.cpp b/daemon/src/rpc/event_loop.cpp new file mode 100644 index 0000000..3713b36 --- /dev/null +++ b/daemon/src/rpc/event_loop.cpp @@ -0,0 +1,109 @@ +#include "rpc/event_loop.hpp" + +#include +#include +#include + +#include +#include +#include +#include + +namespace velox::daemon::rpc { + +EventLoop::EventLoop() { + wake_fd_ = ::eventfd(0, EFD_NONBLOCK | EFD_CLOEXEC); + if (wake_fd_ < 0) throw std::runtime_error("eventfd() failed"); + fds_.emplace(wake_fd_, Entry{kRead, [this](int, unsigned) { drain_wakeup(); }}); +} + +EventLoop::~EventLoop() { + if (wake_fd_ >= 0) ::close(wake_fd_); +} + +void EventLoop::add_fd(int fd, unsigned interest, Callback cb) { + fds_[fd] = Entry{interest, std::move(cb)}; +} + +void EventLoop::mod_fd(int fd, unsigned interest) { + if (auto it = fds_.find(fd); it != fds_.end()) it->second.interest = interest; +} + +void EventLoop::del_fd(int fd) { + if (fd == wake_fd_) return; // internal, never removed + fds_.erase(fd); +} + +void EventLoop::wake() noexcept { + const std::uint64_t one = 1; + // Best-effort: an EAGAIN here means a wakeup is already pending, which is fine. + [[maybe_unused]] ssize_t n = ::write(wake_fd_, &one, sizeof(one)); +} + +void EventLoop::stop() noexcept { + stop_requested_ = true; + wake(); +} + +void EventLoop::drain_wakeup() noexcept { + std::uint64_t sink = 0; + while (::read(wake_fd_, &sink, sizeof(sink)) > 0) { + } +} + +void EventLoop::run() { + if (running_) throw std::logic_error("EventLoop::run() is not re-entrant"); + running_ = true; + stop_requested_ = false; + + std::vector pfds; + std::vector fired; + + while (!stop_requested_) { + pfds.clear(); + pfds.reserve(fds_.size()); + for (const auto& [fd, e] : fds_) { + short ev = 0; + if (e.interest & kRead) ev |= POLLIN; + if (e.interest & kWrite) ev |= POLLOUT; + if (ev == 0 && fd != wake_fd_) continue; + pollfd p{}; + p.fd = fd; + p.events = ev; + pfds.push_back(p); + } + + const int rc = ::poll(pfds.data(), pfds.size(), -1); + if (rc < 0) { + if (errno == EINTR) continue; + throw std::runtime_error("poll() failed"); + } + if (rc == 0) continue; + + // Snapshot the fds that fired before invoking any callback: a callback may erase + // entries from fds_, which would invalidate iteration over pfds' referents. + fired.clear(); + for (const auto& p : pfds) { + if (p.revents != 0) fired.push_back(p.fd); + } + + for (const int fd : fired) { + const auto it = fds_.find(fd); + if (it == fds_.end()) continue; // removed by an earlier callback this pass + + // Recompute revents for this fd from the snapshot. + unsigned events = 0; + for (const auto& p : pfds) { + if (p.fd != fd) continue; + if (p.revents & (POLLIN | POLLHUP | POLLERR)) events |= kRead; + if (p.revents & POLLOUT) events |= kWrite; + break; + } + if (events != 0) it->second.cb(fd, events); + } + } + + running_ = false; +} + +} // namespace velox::daemon::rpc diff --git a/daemon/src/rpc/event_loop.hpp b/daemon/src/rpc/event_loop.hpp new file mode 100644 index 0000000..8f96b4a --- /dev/null +++ b/daemon/src/rpc/event_loop.hpp @@ -0,0 +1,69 @@ +#pragma once + +// A single-threaded poll(2) reactor. Every RPC listener and connection registers its fd +// here; the loop never blocks on disk or DNS (AGENT-DAEMON.md build step 1 — "Never block +// the RPC loop"). Long work is handed to CORE's pools later; this class only multiplexes +// readiness. +// +// Thread model: run() executes on one thread. add_fd/mod_fd/del_fd are called from +// callbacks on that same thread. stop() and wake() are async-signal-safe and safe to call +// from any thread or a signal handler — they only write() a byte to an internal eventfd. + +#include +#include +#include +#include + +namespace velox::daemon::rpc { + +enum Interest : unsigned { + kNone = 0, + kRead = 1u << 0, + kWrite = 1u << 1, +}; + +class EventLoop { +public: + // Called when the fd is readable and/or writable. `events` is the subset of the fd's + // registered Interest that fired. A callback may add/modify/remove any fd, including + // its own, and may call stop(). + using Callback = std::function; + + EventLoop(); + ~EventLoop(); + + EventLoop(const EventLoop&) = delete; + EventLoop& operator=(const EventLoop&) = delete; + + // Register `fd` (must be non-blocking) for `interest`. Replaces any prior registration. + void add_fd(int fd, unsigned interest, Callback cb); + // Change the interest mask for an already-registered fd. + void mod_fd(int fd, unsigned interest); + // Stop watching `fd`. Does not close it — ownership stays with the caller. + void del_fd(int fd); + + // Run until stop() is called. Re-entrant calls are not supported. + void run(); + + // Ask run() to return after the current poll wakeup. Async-signal-safe. + void stop() noexcept; + + // Force one poll() wakeup without stopping — used when interest changed from outside a + // callback. Async-signal-safe. + void wake() noexcept; + +private: + struct Entry { + unsigned interest; + Callback cb; + }; + + void drain_wakeup() noexcept; + + int wake_fd_; // eventfd, always registered + bool running_ = false; + std::atomic stop_requested_ = false; // set from stop(), read by run() + std::unordered_map fds_; +}; + +} // namespace velox::daemon::rpc diff --git a/daemon/src/rpc/ndjson.hpp b/daemon/src/rpc/ndjson.hpp new file mode 100644 index 0000000..ef79b23 --- /dev/null +++ b/daemon/src/rpc/ndjson.hpp @@ -0,0 +1,66 @@ +#pragma once + +// NDJSON framing: one JSON value per line, '\n'-terminated. This is the wire framing on +// the Unix socket ($XDG_RUNTIME_DIR/velox/velox.sock) per AGENT-DAEMON.md build step 1. +// A frame carries no length prefix — the newline is the delimiter — so a reader must +// buffer a partial tail until the next '\n' arrives. +// +// Header-only: it is pure string slicing with no I/O and no dependency beyond . + +#include +#include +#include +#include + +namespace velox::daemon::rpc { + +// Largest single frame accepted before the connection is considered abusive. A well-formed +// request (even download.addBatch with a big clipboard blob) is far below this; anything +// past it is either a bug or an attack, and the server drops the connection. +inline constexpr std::size_t kMaxFrameBytes = 8 * 1024 * 1024; + +// Accumulates bytes off a stream socket and hands back complete lines. Bytes after the +// last '\n' stay buffered for next time. A trailing '\r' (CRLF) is trimmed so a client +// that writes CRLF still parses. +class FrameReader { +public: + // Feed a chunk just read from the socket. Returns the frames completed by this chunk, + // in order, each with its line terminator removed. Empty lines are skipped (a stray + // blank line between frames is not an error). + std::vector feed(std::string_view chunk) { + std::vector out; + buf_.append(chunk); + std::size_t start = 0; + for (;;) { + const std::size_t nl = buf_.find('\n', start); + if (nl == std::string::npos) break; + std::string_view line{buf_.data() + start, nl - start}; + if (!line.empty() && line.back() == '\r') line.remove_suffix(1); + if (!line.empty()) out.emplace_back(line); + start = nl + 1; + } + buf_.erase(0, start); + return out; + } + + // True once the unframed tail has grown past the cap without a newline — the caller + // must close the connection rather than buffer without bound. + bool overflowed() const noexcept { return buf_.size() > kMaxFrameBytes; } + + std::size_t buffered() const noexcept { return buf_.size(); } + +private: + std::string buf_; +}; + +// Frame a payload for writing: exactly the JSON text plus one '\n'. Kept as a function so +// the "+ newline" rule lives in one place. +inline std::string frame(std::string_view payload) { + std::string out; + out.reserve(payload.size() + 1); + out.append(payload); + out.push_back('\n'); + return out; +} + +} // namespace velox::daemon::rpc diff --git a/daemon/src/rpc/runtime_dir.cpp b/daemon/src/rpc/runtime_dir.cpp new file mode 100644 index 0000000..e387916 --- /dev/null +++ b/daemon/src/rpc/runtime_dir.cpp @@ -0,0 +1,56 @@ +#include "rpc/runtime_dir.hpp" + +#include +#include +#include + +#include +#include +#include + +namespace velox::daemon::rpc { + +namespace { + +std::error_code errc(int e) { return std::error_code(e, std::generic_category()); } + +// Ensure `dir` exists as a directory we own with mode 0700. Creates it if absent. +std::error_code ensure_private_dir(const std::string& dir) { + if (::mkdir(dir.c_str(), 0700) != 0 && errno != EEXIST) return errc(errno); + + struct stat st{}; + if (::lstat(dir.c_str(), &st) != 0) return errc(errno); + if (!S_ISDIR(st.st_mode)) return errc(ENOTDIR); + if (st.st_uid != ::geteuid()) return errc(EPERM); + + // Tighten if a prior run (or umask) left it looser. Group/other bits must be clear: + // the socket is 0600 but a traversable parent still lets another user stat it. + if ((st.st_mode & 077) != 0 && ::chmod(dir.c_str(), 0700) != 0) return errc(errno); + return {}; +} + +} // namespace + +std::error_code resolve_runtime_dir(RuntimeDir& out) { + std::string base; + if (const char* xdg = ::getenv("XDG_RUNTIME_DIR"); xdg != nullptr && xdg[0] != '\0') { + base = xdg; + } else { + base = "/run/user/" + std::to_string(::geteuid()); + struct stat st{}; + if (::stat(base.c_str(), &st) != 0 || !S_ISDIR(st.st_mode)) { + // No XDG_RUNTIME_DIR and no /run/user/: we refuse rather than pick an + // insecure fallback. The caller surfaces this as "cannot start". + return errc(ENOENT); + } + } + if (!base.empty() && base.back() == '/') base.pop_back(); + + const std::string dir = base + "/velox"; + if (auto ec = ensure_private_dir(dir)) return ec; + + out.path = dir; + return {}; +} + +} // namespace velox::daemon::rpc diff --git a/daemon/src/rpc/runtime_dir.hpp b/daemon/src/rpc/runtime_dir.hpp new file mode 100644 index 0000000..b71a529 --- /dev/null +++ b/daemon/src/rpc/runtime_dir.hpp @@ -0,0 +1,30 @@ +#pragma once + +// Resolves $XDG_RUNTIME_DIR/velox/ — the home of velox.sock, ws.port and the +// single-instance lock (docs/01 §5, AGENT-DAEMON.md build step 1). Creating it 0700 and +// refusing a pre-existing dir we do not own is a security boundary, not a convenience. + +#include +#include + +namespace velox::daemon::rpc { + +struct RuntimeDir { + std::string path; // absolute, no trailing slash, e.g. /run/user/1000/velox + + std::string socket_path() const { return path + "/velox.sock"; } + std::string ws_port_path() const { return path + "/ws.port"; } +}; + +// Resolve and ensure the directory exists, mode 0700, owned by the current user. +// +// - $XDG_RUNTIME_DIR set -> "/velox" +// - unset -> "/run/user//velox" if that base exists, else an error +// (we do not fall back to /tmp: a world-traversable runtime dir defeats the 0600 socket) +// +// On success `out` is filled and an ok error_code is returned. On failure `out` is +// untouched and the error_code explains why (base missing, exists but not a dir, wrong +// owner, wrong perms, mkdir failed). +std::error_code resolve_runtime_dir(RuntimeDir& out); + +} // namespace velox::daemon::rpc diff --git a/daemon/src/rpc/uds_server.cpp b/daemon/src/rpc/uds_server.cpp new file mode 100644 index 0000000..9437090 --- /dev/null +++ b/daemon/src/rpc/uds_server.cpp @@ -0,0 +1,309 @@ +#include "rpc/uds_server.hpp" + +#include +#include +#include +#include + +#include +#include +#include +#include + +#include + +#include "rpc/event_loop.hpp" +#include "version.hpp" + +namespace velox::daemon::rpc { + +namespace proto = velox::proto; +using nlohmann::json; + +namespace { + +std::error_code errc(int e) { return std::error_code(e, std::generic_category()); } + +// Largest reply we will buffer for a client that is not reading. Past this the client is +// wedged and the connection is dropped rather than growing the daemon's RSS without bound. +constexpr std::size_t kMaxOutBytes = 16 * 1024 * 1024; + +std::string uuid4() { + std::random_device rd; + std::uniform_int_distribution d; + std::uint32_t a = d(rd), b = d(rd), c = d(rd), e = d(rd); + b = (b & 0xFFFF0FFFu) | 0x00004000u; // version 4 + c = (c & 0x3FFFFFFFu) | 0x80000000u; // variant 1 + char buf[37]; + std::snprintf(buf, sizeof(buf), "%08x-%04x-%04x-%04x-%04x%08x", a, (b >> 16), (b & 0xFFFF), + (c >> 16), (c & 0xFFFF), e); + return std::string(buf); +} + +int major_of(const std::string& semver) { + // "1.3.0" -> 1. A missing or non-numeric leading component is treated as major -1 so + // it can never accidentally match the daemon's. + try { + return std::stoi(semver.substr(0, semver.find('.'))); + } catch (...) { + return -1; + } +} + +json rpc_error(const json& id, proto::ErrorCode code, std::string_view msg, json data = nullptr) { + return proto::make_error(id, code, msg, std::move(data)); +} + +} // namespace + +UdsServer::UdsServer(EventLoop& loop, proto::Dispatcher& dispatcher, std::string socket_path) + : loop_(loop), dispatcher_(dispatcher), path_(std::move(socket_path)) {} + +UdsServer::~UdsServer() { + for (auto& [fd, c] : conns_) { + loop_.del_fd(fd); + ::close(fd); + } + if (listen_fd_ >= 0) { + loop_.del_fd(listen_fd_); + ::close(listen_fd_); + } + if (bound_) ::unlink(path_.c_str()); +} + +std::error_code UdsServer::start() { + 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); + if (fd < 0) return errc(errno); + + // A socket file from a previous run blocks bind() with EADDRINUSE. Single-instance is + // enforced separately (main.cpp lock socket), so an existing file here is stale. + ::unlink(path_.c_str()); + + sockaddr_un addr{}; + addr.sun_family = AF_UNIX; + std::memcpy(addr.sun_path, path_.c_str(), path_.size()); + + // umask can only tighten; set the mode explicitly after bind so it is exactly 0600. + if (::bind(fd, reinterpret_cast(&addr), sizeof(addr)) != 0) { + const int e = errno; + ::close(fd); + return errc(e); + } + bound_ = true; + if (::chmod(path_.c_str(), 0600) != 0) { + const int e = errno; + ::close(fd); + ::unlink(path_.c_str()); + bound_ = false; + return errc(e); + } + if (::listen(fd, SOMAXCONN) != 0) { + const int e = errno; + ::close(fd); + ::unlink(path_.c_str()); + bound_ = false; + return errc(e); + } + + listen_fd_ = fd; + loop_.add_fd(listen_fd_, kRead, [this](int, unsigned) { on_listener_readable(); }); + return {}; +} + +void UdsServer::on_listener_readable() { + for (;;) { + const int cfd = ::accept4(listen_fd_, nullptr, nullptr, SOCK_NONBLOCK | SOCK_CLOEXEC); + if (cfd < 0) { + if (errno == EAGAIN || errno == EWOULDBLOCK) break; + if (errno == EINTR || errno == ECONNABORTED) continue; + break; // EMFILE/ENFILE: stop accepting this pass; loop retries on next readable + } + + ucred cred{}; + socklen_t len = sizeof(cred); + if (::getsockopt(cfd, SOL_SOCKET, SO_PEERCRED, &cred, &len) != 0 || + cred.uid != ::geteuid()) { + // Not the same user. The socket mode should already prevent this; refuse hard + // regardless — this is the authorization on the Unix transport (docs/01 §2). + ::close(cfd); + continue; + } + + auto conn = std::make_unique(); + conn->fd = cfd; + conns_.emplace(cfd, std::move(conn)); + loop_.add_fd(cfd, kRead, [this](int fd, unsigned ev) { on_conn_event(fd, ev); }); + } +} + +void UdsServer::on_conn_event(int fd, unsigned events) { + const auto it = conns_.find(fd); + if (it == conns_.end()) return; + Conn& c = *it->second; + + if (events & kWrite) { + flush(c); + if (conns_.find(fd) == conns_.end()) return; // flush closed it + } + + if (events & kRead) { + char buf[64 * 1024]; + for (;;) { + const ssize_t n = ::read(fd, buf, sizeof(buf)); + if (n > 0) { + auto lines = c.reader.feed(std::string_view(buf, static_cast(n))); + const bool overflowed = c.reader.overflowed(); + for (auto& line : lines) { + handle_line(c, line); + // handle_line may have replied with a fatal error and closed the + // connection (e.g. a protocol-major mismatch). Once that happens `c` + // is dangling — stop touching it. + if (conns_.find(fd) == conns_.end()) return; + } + if (overflowed) { + close_conn(fd); + return; + } + continue; + } + if (n == 0) { // peer closed + close_conn(fd); + return; + } + if (errno == EAGAIN || errno == EWOULDBLOCK) break; + if (errno == EINTR) continue; + close_conn(fd); + return; + } + } +} + +void UdsServer::handle_line(Conn& c, const std::string& line) { + json req = json::parse(line, nullptr, /*allow_exceptions=*/false); + if (req.is_discarded()) { + queue_reply(c, rpc_error(nullptr, proto::ErrorCode::ParseError, "invalid JSON")); + return; + } + + const json id = req.is_object() && req.contains("id") ? req.at("id") : json(nullptr); + const std::string method = + req.is_object() && req.contains("method") && req.at("method").is_string() + ? req.at("method").get() + : std::string{}; + + if (!method.empty()) { + json reply; + if (handle_session_method(c, method, req, reply)) { + if (!reply.is_null()) queue_reply(c, reply); + return; + } + } + + // Everything else: the generated router. It returns a null json for a notification + // that needs no reply. + json reply = proto::dispatch(dispatcher_, proto::Transport::Uds, req); + if (!reply.is_null()) queue_reply(c, reply); +} + +bool UdsServer::handle_session_method(Conn& c, const std::string& method, const json& request, + json& reply) { + const json id = request.contains("id") ? request.at("id") : json(nullptr); + const json params = request.contains("params") ? request.at("params") : json::object(); + + if (method == "session.hello") { + auto p = proto::parse(params, "params"); + if (!p) { + reply = rpc_error(id, proto::ErrorCode::InvalidParams, p.error().message, + json{{"path", p.error().path}}); + return true; + } + const int want = major_of(std::string(proto::kProtocolVersion)); + const int got = major_of(p->protocolVersion); + if (got != want) { + reply = rpc_error( + id, proto::ErrorCode::VersionMismatch, + "protocol major version mismatch: daemon speaks " + std::to_string(want) + + ".x, client speaks " + std::to_string(got < 0 ? 0 : got) + ".x", + json{{"expected", std::string(proto::kProtocolVersion)}, + {"actual", p->protocolVersion}}); + c.close_after_flush = true; // no method is served on a mismatched major + return true; + } + + c.hello_ok = true; + if (c.session_id.empty()) c.session_id = uuid4(); + + proto::SessionHelloResult r; + r.daemonVersion = std::string(velox::daemon::kDaemonVersion); + r.protocolVersion = std::string(proto::kProtocolVersion); + r.sessionId = c.session_id; + r.transport = proto::SessionHelloResultTransport::Uds; + reply = proto::make_result(id, r); + return true; + } + + if (method == "session.subscribe") { + auto p = proto::parse(params, "params"); + if (!p) { + reply = rpc_error(id, proto::ErrorCode::InvalidParams, p.error().message, + json{{"path", p.error().path}}); + return true; + } + // Event fan-out is not wired yet; accept the subscription and echo it back so a + // client can already register its interest without erroring. + proto::SessionSubscribeResult r; + r.ok = true; + for (const auto& ev : p->events) r.events.emplace_back(proto::to_string(ev)); + reply = proto::make_result(id, r); + return true; + } + + return false; // session.pair falls through to dispatch() -> -32003 on the Unix socket +} + +void UdsServer::queue_reply(Conn& c, const json& reply) { + c.outbuf += frame(reply.dump()); + if (c.outbuf.size() - c.out_off > kMaxOutBytes) { + close_conn(c.fd); + return; + } + flush(c); +} + +void UdsServer::flush(Conn& c) { + while (c.out_off < c.outbuf.size()) { + const ssize_t n = + ::write(c.fd, c.outbuf.data() + c.out_off, c.outbuf.size() - c.out_off); + if (n > 0) { + c.out_off += static_cast(n); + continue; + } + if (n < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) { + loop_.mod_fd(c.fd, kRead | kWrite); + return; + } + if (n < 0 && errno == EINTR) continue; + close_conn(c.fd); + return; + } + + c.outbuf.clear(); + c.out_off = 0; + if (c.close_after_flush) { + close_conn(c.fd); + return; + } + loop_.mod_fd(c.fd, kRead); +} + +void UdsServer::close_conn(int fd) { + if (const auto it = conns_.find(fd); it != conns_.end()) { + loop_.del_fd(fd); + ::close(fd); + conns_.erase(it); + } +} + +} // namespace velox::daemon::rpc diff --git a/daemon/src/rpc/uds_server.hpp b/daemon/src/rpc/uds_server.hpp new file mode 100644 index 0000000..48cf389 --- /dev/null +++ b/daemon/src/rpc/uds_server.hpp @@ -0,0 +1,77 @@ +#pragma once + +// The Unix-domain-socket RPC listener: $XDG_RUNTIME_DIR/velox/velox.sock, mode 0600, +// SO_PEERCRED same-UID check (docs/01 §2, AGENT-DAEMON.md build step 1). NDJSON framing. +// Non-blocking throughout; every fd runs through the shared EventLoop so one slow client +// never stalls another. +// +// session.hello and session.subscribe are handled here because they are connection- and +// transport-stateful (protocol-major check, sessionId, per-connection subscription set). +// Every other method is routed through the generated velox::proto::dispatch(), which does +// the envelope, the -32003 privileged-transport refusal and the typed param parse. + +#include +#include +#include +#include +#include +#include + +#include + +#include "rpc/ndjson.hpp" +#include "velox_proto.hpp" + +namespace velox::daemon::rpc { + +class EventLoop; + +class UdsServer { +public: + UdsServer(EventLoop& loop, velox::proto::Dispatcher& dispatcher, std::string socket_path); + ~UdsServer(); + + UdsServer(const UdsServer&) = delete; + UdsServer& operator=(const UdsServer&) = delete; + + // Create the socket, bind, chmod 0600, listen, and register with the loop. A stale + // socket file left by a crashed daemon is removed first. Returns a non-ok error_code + // (and changes nothing) on any failure. + std::error_code start(); + + const std::string& socket_path() const noexcept { return path_; } + std::size_t connection_count() const noexcept { return conns_.size(); } + +private: + struct Conn { + int fd; + FrameReader reader; + std::string outbuf; + std::size_t out_off = 0; // bytes of outbuf already written + bool close_after_flush = false; + bool hello_ok = false; + std::string session_id; + }; + + void on_listener_readable(); + void on_conn_event(int fd, unsigned events); + void handle_line(Conn& c, const std::string& line); + + // Returns true and fills `reply` if `method` is one this layer answers directly + // (session.hello / session.subscribe). Returns false to let dispatch() handle it. + bool handle_session_method(Conn& c, const std::string& method, const nlohmann::json& request, + nlohmann::json& reply); + + void queue_reply(Conn& c, const nlohmann::json& reply); + void flush(Conn& c); + void close_conn(int fd); + + EventLoop& loop_; + velox::proto::Dispatcher& dispatcher_; + std::string path_; + int listen_fd_ = -1; + bool bound_ = false; // path_ is ours to unlink on destruction + std::unordered_map> conns_; +}; + +} // namespace velox::daemon::rpc diff --git a/daemon/src/version.hpp b/daemon/src/version.hpp new file mode 100644 index 0000000..e8777d0 --- /dev/null +++ b/daemon/src/version.hpp @@ -0,0 +1,12 @@ +#pragma once + +#include + +namespace velox::daemon { + +// The daemon's own build version, reported in session.hello as daemonVersion. Distinct +// from the protocol version (velox::proto::kProtocolVersion), which is what the major +// compatibility check keys on. +inline constexpr std::string_view kDaemonVersion = "0.1.0"; + +} // namespace velox::daemon diff --git a/daemon/tests/CMakeLists.txt b/daemon/tests/CMakeLists.txt new file mode 100644 index 0000000..04983f7 --- /dev/null +++ b/daemon/tests/CMakeLists.txt @@ -0,0 +1,14 @@ +# daemon unit + integration tests. Registered with ctest; run via `ctest --preset dev`. +# No external test framework — each file is a small self-checking binary (matches the +# lightweight style core/ uses, without depending on core's private test support). + +add_executable(veloxd_ndjson_test ndjson_test.cpp) +target_link_libraries(veloxd_ndjson_test PRIVATE veloxd_rpc) +target_compile_options(veloxd_ndjson_test PRIVATE -Wall -Wextra -Wpedantic -Werror) +add_test(NAME veloxd.ndjson COMMAND veloxd_ndjson_test) + +add_executable(veloxd_uds_roundtrip_test uds_roundtrip_test.cpp) +target_link_libraries(veloxd_uds_roundtrip_test PRIVATE veloxd_rpc) +target_compile_options(veloxd_uds_roundtrip_test PRIVATE -Wall -Wextra -Wpedantic -Werror) +add_test(NAME veloxd.uds_roundtrip COMMAND veloxd_uds_roundtrip_test) +set_tests_properties(veloxd.uds_roundtrip PROPERTIES TIMEOUT 30) diff --git a/daemon/tests/check.hpp b/daemon/tests/check.hpp new file mode 100644 index 0000000..5e5ce64 --- /dev/null +++ b/daemon/tests/check.hpp @@ -0,0 +1,53 @@ +#pragma once + +// Minimal test harness: CHECK accumulates failures, TEST_MAIN reports and sets the exit +// code. Deliberately tiny — the daemon does not pull in a test framework for this. + +#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/daemon/tests/ndjson_test.cpp b/daemon/tests/ndjson_test.cpp new file mode 100644 index 0000000..ef6b1c1 --- /dev/null +++ b/daemon/tests/ndjson_test.cpp @@ -0,0 +1,64 @@ +#include "rpc/ndjson.hpp" + +#include + +#include "check.hpp" + +using velox::daemon::rpc::FrameReader; +using velox::daemon::rpc::frame; + +void run() { + // One chunk, one frame. + { + FrameReader r; + auto f = r.feed("{\"a\":1}\n"); + CHECK_EQ(f.size(), 1u); + CHECK_EQ(f.at(0), std::string("{\"a\":1}")); + CHECK_EQ(r.buffered(), 0u); + } + + // A frame split across two feeds is delivered only when the newline arrives. + { + FrameReader r; + CHECK_EQ(r.feed("{\"a\":").size(), 0u); + auto f = r.feed("1}\n"); + CHECK_EQ(f.size(), 1u); + CHECK_EQ(f.at(0), std::string("{\"a\":1}")); + } + + // Several frames in one chunk, plus a partial tail held back. + { + FrameReader r; + auto f = r.feed("1\n2\n3\n4"); + CHECK_EQ(f.size(), 3u); + CHECK_EQ(f.at(0), std::string("1")); + CHECK_EQ(f.at(2), std::string("3")); + CHECK_EQ(r.buffered(), 1u); + auto g = r.feed("\n"); + CHECK_EQ(g.size(), 1u); + CHECK_EQ(g.at(0), std::string("4")); + } + + // CRLF terminator is trimmed; blank lines are skipped. + { + FrameReader r; + auto f = r.feed("x\r\n\r\n\ny\r\n"); + CHECK_EQ(f.size(), 2u); + CHECK_EQ(f.at(0), std::string("x")); + CHECK_EQ(f.at(1), std::string("y")); + } + + // Overflow latches once the unframed tail passes the cap. + { + FrameReader r; + CHECK(!r.overflowed()); + std::string big(9u * 1024 * 1024, 'a'); // no newline + r.feed(big); + CHECK(r.overflowed()); + } + + // frame() appends exactly one newline. + CHECK_EQ(frame("hello"), std::string("hello\n")); +} + +TEST_MAIN() diff --git a/daemon/tests/uds_roundtrip_test.cpp b/daemon/tests/uds_roundtrip_test.cpp new file mode 100644 index 0000000..39f8ea9 --- /dev/null +++ b/daemon/tests/uds_roundtrip_test.cpp @@ -0,0 +1,195 @@ +// Integration: a real UdsServer on a temp socket, a real client socket, NDJSON round trips. +// Proves the transport, the generated dispatch() wiring, and the session-layer handling. + +#include +#include +#include + +#include +#include +#include +#include + +#include + +#include "check.hpp" +#include "rpc/dispatcher.hpp" +#include "rpc/event_loop.hpp" +#include "rpc/ndjson.hpp" +#include "rpc/uds_server.hpp" +#include "velox_proto.hpp" + +using nlohmann::json; +namespace rpc = velox::daemon::rpc; + +namespace { + +std::string make_temp_socket_path() { + char tmpl[] = "/tmp/veloxd-test-XXXXXX"; + const char* dir = ::mkdtemp(tmpl); + return std::string(dir ? dir : "/tmp") + "/velox.sock"; +} + +int connect_client(const std::string& path) { + const int fd = ::socket(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0); + sockaddr_un addr{}; + addr.sun_family = AF_UNIX; + 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; +} + +// Send one framed request, read one framed reply (blocking client; the server is async). +json call(int fd, const json& request) { + const std::string out = rpc::frame(request.dump()); + if (::write(fd, out.data(), out.size()) != static_cast(out.size())) return {}; + + std::string buf; + char chunk[4096]; + for (;;) { + const ssize_t n = ::read(fd, chunk, sizeof(chunk)); + if (n <= 0) return {}; + buf.append(chunk, static_cast(n)); + if (const auto nl = buf.find('\n'); nl != std::string::npos) + return json::parse(buf.substr(0, nl), nullptr, false); + } +} + +} // namespace + +void run() { + const std::string sock = make_temp_socket_path(); + + rpc::EventLoop loop; + rpc::VeloxDispatcher dispatcher; + rpc::UdsServer server(loop, dispatcher, sock); + const auto ec = server.start(); + CHECK(!ec); + if (ec) return; + + std::thread loop_thread([&loop] { loop.run(); }); + + // --- session.hello, matching major -> a real SessionHelloResult ------------------- + { + const int c = connect_client(sock); + CHECK(c >= 0); + const json reply = call(c, {{"jsonrpc", "2.0"}, + {"id", 1}, + {"method", "session.hello"}, + {"params", + {{"clientType", "test"}, + {"clientName", "roundtrip"}, + {"protocolVersion", std::string(velox::proto::kProtocolVersion)}}}}); + CHECK(reply.contains("result")); + CHECK_EQ(reply["id"].get(), 1); + CHECK_EQ(reply["result"]["protocolVersion"].get(), + std::string(velox::proto::kProtocolVersion)); + CHECK_EQ(reply["result"]["transport"].get(), std::string("uds")); + CHECK(!reply["result"]["sessionId"].get().empty()); + ::close(c); + } + + // --- session.hello, wrong major -> -32001, connection closed after the reply ------ + { + const int c = connect_client(sock); + const json reply = call(c, {{"jsonrpc", "2.0"}, + {"id", 2}, + {"method", "session.hello"}, + {"params", + {{"clientType", "gui"}, + {"clientName", "from the future"}, + {"protocolVersion", "2.0.0"}}}}); + CHECK(reply.contains("error")); + CHECK_EQ(reply["error"]["code"].get(), -32001); + CHECK_EQ(reply["error"]["data"]["actual"].get(), std::string("2.0.0")); + ::close(c); + } + + // --- download.list -> an empty table (dispatcher answers this one for real) ------- + { + const int c = connect_client(sock); + const json reply = + call(c, {{"jsonrpc", "2.0"}, {"id", 3}, {"method", "download.list"}, {"params", json::object()}}); + CHECK(reply.contains("result")); + CHECK_EQ(reply["result"]["total"].get(), 0); + CHECK(reply["result"]["items"].is_array()); + CHECK_EQ(reply["result"]["items"].size(), 0u); + ::close(c); + } + + // --- an unknown method -> -32601 ------------------------------------------------ + { + const int c = connect_client(sock); + const json reply = + call(c, {{"jsonrpc", "2.0"}, {"id", 4}, {"method", "no.such.method"}, {"params", json::object()}}); + CHECK(reply.contains("error")); + CHECK_EQ(reply["error"]["code"].get(), -32601); + ::close(c); + } + + // --- malformed JSON -> -32700, id null ---------------------------------------- + { + const int c = connect_client(sock); + const std::string bad = "{ this is not json )\n"; + CHECK(::write(c, bad.data(), bad.size()) == static_cast(bad.size())); + std::string buf; + char chunk[1024]; + const ssize_t n = ::read(c, chunk, sizeof(chunk)); + CHECK(n > 0); + if (n > 0) { + buf.assign(chunk, static_cast(n)); + const json reply = json::parse(buf.substr(0, buf.find('\n')), nullptr, false); + CHECK_EQ(reply["error"]["code"].get(), -32700); + CHECK(reply["id"].is_null()); + } + ::close(c); + } + + // --- download.get -> -32603 for now: documents the P1 codegen gap --------------- + // (proto-requests-m1.md P1: handlers cannot yet return -32010. When P1 lands this + // check flips to -32010 and is the regression guard for it.) + { + const int c = connect_client(sock); + const json reply = call(c, {{"jsonrpc", "2.0"}, + {"id", 6}, + {"method", "download.get"}, + {"params", {{"taskId", "00000000-0000-4000-8000-000000000000"}}}}); + CHECK(reply.contains("error")); + CHECK_EQ(reply["error"]["code"].get(), -32603); + ::close(c); + } + + // --- two requests in one write, pipelined on one connection -------------------- + { + const int c = connect_client(sock); + std::string out = rpc::frame(json({{"jsonrpc", "2.0"}, {"id", 7}, {"method", "download.list"}, {"params", json::object()}}).dump()); + out += rpc::frame(json({{"jsonrpc", "2.0"}, {"id", 8}, {"method", "download.list"}, {"params", json::object()}}).dump()); + CHECK(::write(c, out.data(), out.size()) == static_cast(out.size())); + std::string buf; + char chunk[4096]; + int seen = 0; + while (seen < 2) { + const ssize_t n = ::read(c, chunk, sizeof(chunk)); + if (n <= 0) break; + buf.append(chunk, static_cast(n)); + std::size_t nl; + while ((nl = buf.find('\n')) != std::string::npos) { + const json reply = json::parse(buf.substr(0, nl), nullptr, false); + CHECK(reply.contains("result")); + ++seen; + buf.erase(0, nl + 1); + } + } + CHECK_EQ(seen, 2); + ::close(c); + } + + loop.stop(); + loop_thread.join(); + ::unlink(sock.c_str()); +} + +TEST_MAIN() From 0c7ce1437cc10ea67b009d8a23383f40d73db658 Mon Sep 17 00:00:00 2001 From: sami Date: Thu, 10 Sep 2026 15:01:29 +0400 Subject: [PATCH 2/7] =?UTF-8?q?cli:=20velox=20=E2=80=94=20add=20/=20ls=20/?= =?UTF-8?q?=20pause=20/=20resume=20/=20rm,=20with=20--json=20(build=20step?= =?UTF-8?q?=208,=20pulled=20forward)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scriptable client, built now rather than last: it is how the daemon gets exercised before the GUI is pointed at it (AGENT-DAEMON.md). - src/client — synchronous blocking RPC over the Unix socket: resolve $XDG_RUNTIME_DIR/velox/velox.sock, connect, session.hello, one framed request/reply per call. Distinguishes transport failure (exit 3) from a daemon-returned error (exit 1). - src/main — subcommands add/ls/pause/resume/rm; --json prints the raw JSON-RPC result or error; --dir/--out/--segments on add; --delete-file on rm. Usage errors exit 2. ls works end to end against veloxd today (empty table). add and the bulk verbs reach the daemon and surface its "not implemented" (-32603) cleanly until the store lands — the plumbing is done, the commands light up as handlers do. Test velox.client: the real Client against an in-process UdsServer — no-daemon path, session.hello, download.list, and a not-implemented method surfacing as an RPC error rather than a transport error. ASan+UBSan clean; full tree (21 tests, incl. conformance) green. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Upd9WhG9oppieig5nRDLig --- cli/CMakeLists.txt | 23 +++++ cli/src/.gitkeep | 0 cli/src/client.cpp | 130 +++++++++++++++++++++++++++ cli/src/client.hpp | 55 ++++++++++++ cli/src/main.cpp | 179 ++++++++++++++++++++++++++++++++++++++ cli/tests/CMakeLists.txt | 13 +++ cli/tests/client_test.cpp | 78 +++++++++++++++++ 7 files changed, 478 insertions(+) create mode 100644 cli/CMakeLists.txt delete mode 100644 cli/src/.gitkeep create mode 100644 cli/src/client.cpp create mode 100644 cli/src/client.hpp create mode 100644 cli/src/main.cpp create mode 100644 cli/tests/CMakeLists.txt create mode 100644 cli/tests/client_test.cpp diff --git a/cli/CMakeLists.txt b/cli/CMakeLists.txt new file mode 100644 index 0000000..4880874 --- /dev/null +++ b/cli/CMakeLists.txt @@ -0,0 +1,23 @@ +# cli/ produces the `velox` binary — the scriptable RPC client. Owned by lane DAEMON. +# Built early (AGENT-DAEMON.md build step 8, pulled forward): it is how the daemon is +# tested before the GUI exists. +# +# Links velox::proto for the wire types and error codes. It speaks the same NDJSON Unix +# socket veloxd listens on; no daemon code is linked. + +if(NOT TARGET nlohmann_json::nlohmann_json) + find_package(nlohmann_json 3.11 REQUIRED) +endif() + +add_executable(velox + src/main.cpp + src/client.cpp +) +target_include_directories(velox PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src) +target_compile_features(velox PRIVATE cxx_std_23) +target_compile_options(velox PRIVATE -Wall -Wextra -Wpedantic -Werror) +target_link_libraries(velox PRIVATE velox::proto nlohmann_json::nlohmann_json) + +if(VELOX_BUILD_TESTS AND EXISTS ${CMAKE_CURRENT_SOURCE_DIR}/tests/CMakeLists.txt) + add_subdirectory(tests) +endif() diff --git a/cli/src/.gitkeep b/cli/src/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/cli/src/client.cpp b/cli/src/client.cpp new file mode 100644 index 0000000..2bbba2f --- /dev/null +++ b/cli/src/client.cpp @@ -0,0 +1,130 @@ +#include "client.hpp" + +#include +#include +#include + +#include +#include +#include + +#include "velox_proto.hpp" + +namespace velox::cli { + +namespace { + +std::string read_error_message(int e) { return std::strerror(e); } + +} // namespace + +std::string default_socket_path() { + std::string base; + if (const char* xdg = ::getenv("XDG_RUNTIME_DIR"); xdg != nullptr && xdg[0] != '\0') { + base = xdg; + } else { + base = "/run/user/" + std::to_string(::geteuid()); + } + if (!base.empty() && base.back() == '/') base.pop_back(); + return base + "/velox/velox.sock"; +} + +Client::~Client() { + if (fd_ >= 0) ::close(fd_); +} + +std::optional Client::connect() { + socket_path_ = default_socket_path(); + if (socket_path_.size() + 1 > sizeof(sockaddr_un::sun_path)) { + return CallError{CallError::kConnect, "socket path too long: " + socket_path_, {}}; + } + + fd_ = ::socket(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0); + if (fd_ < 0) return CallError{CallError::kConnect, read_error_message(errno), {}}; + + sockaddr_un addr{}; + addr.sun_family = AF_UNIX; + std::memcpy(addr.sun_path, socket_path_.c_str(), socket_path_.size()); + if (::connect(fd_, reinterpret_cast(&addr), sizeof(addr)) != 0) { + const int e = errno; + ::close(fd_); + fd_ = -1; + return CallError{CallError::kConnect, + "cannot reach veloxd at " + socket_path_ + ": " + read_error_message(e), + {}}; + } + + nlohmann::json hello = { + {"clientType", "cli"}, + {"clientName", std::string("velox ") + std::string(velox::proto::kProtocolVersion)}, + {"protocolVersion", std::string(velox::proto::kProtocolVersion)}, + }; + auto r = call("session.hello", hello); + if (!r) return r.error(); + hello_result_ = *r; + return std::nullopt; +} + +std::expected Client::call(const std::string& method, + const nlohmann::json& params) { + const nlohmann::json request = { + {"jsonrpc", "2.0"}, + {"id", next_id_++}, + {"method", method}, + {"params", params}, + }; + return round_trip(request); +} + +std::expected Client::round_trip(const nlohmann::json& request) { + if (fd_ < 0) return std::unexpected(CallError{CallError::kConnect, "not connected", {}}); + + std::string out = request.dump(); + out.push_back('\n'); + std::size_t off = 0; + while (off < out.size()) { + const ssize_t n = ::write(fd_, out.data() + off, out.size() - off); + if (n > 0) { + off += static_cast(n); + continue; + } + if (n < 0 && errno == EINTR) continue; + return std::unexpected(CallError{CallError::kConnect, + "write to daemon failed: " + read_error_message(errno), {}}); + } + + // Read until a newline completes a frame. + for (;;) { + if (const auto nl = inbuf_.find('\n'); nl != std::string::npos) { + const std::string line = inbuf_.substr(0, nl); + inbuf_.erase(0, nl + 1); + nlohmann::json reply = nlohmann::json::parse(line, nullptr, false); + if (reply.is_discarded()) { + return std::unexpected( + CallError{CallError::kProtocol, "daemon sent a malformed reply", {}}); + } + if (reply.contains("error")) { + const auto& e = reply.at("error"); + return std::unexpected(CallError{e.value("code", 0), e.value("message", ""), + e.contains("data") ? e.at("data") : nlohmann::json()}); + } + return reply.contains("result") ? reply.at("result") : nlohmann::json(nullptr); + } + + char chunk[8192]; + const ssize_t n = ::read(fd_, chunk, sizeof(chunk)); + if (n > 0) { + inbuf_.append(chunk, static_cast(n)); + continue; + } + if (n == 0) { + return std::unexpected(CallError{CallError::kConnect, + "daemon closed the connection", {}}); + } + if (errno == EINTR) continue; + return std::unexpected(CallError{CallError::kConnect, + "read from daemon failed: " + read_error_message(errno), {}}); + } +} + +} // namespace velox::cli diff --git a/cli/src/client.hpp b/cli/src/client.hpp new file mode 100644 index 0000000..1ddbae1 --- /dev/null +++ b/cli/src/client.hpp @@ -0,0 +1,55 @@ +#pragma once + +// A synchronous, blocking RPC client for the Unix socket. The CLI does one request at a +// time and waits for the reply, so none of the daemon's async machinery is needed here — +// just connect, session.hello, call, read one NDJSON frame back. + +#include +#include +#include + +#include + +namespace velox::cli { + +struct CallError { + int code; // JSON-RPC error code, or a negative transport code below + std::string message; + nlohmann::json data; // may be null + + static constexpr int kConnect = -1000; // could not reach the daemon + static constexpr int kProtocol = -1001; // malformed reply / framing error +}; + +class Client { +public: + ~Client(); + + // Resolve the socket path ($XDG_RUNTIME_DIR/velox/velox.sock), connect, and complete + // session.hello with clientType "cli". On failure returns the error and leaves the + // client unusable. + std::optional connect(); + + // Send one request and return its result, or the error. `params` is passed through + // verbatim as the JSON-RPC params. + std::expected call(const std::string& method, + const nlohmann::json& params); + + const std::string& socket_path() const noexcept { return socket_path_; } + const nlohmann::json& hello_result() const noexcept { return hello_result_; } + +private: + std::expected round_trip(const nlohmann::json& request); + + int fd_ = -1; + int next_id_ = 1; + std::string socket_path_; + std::string inbuf_; + nlohmann::json hello_result_; +}; + +// $XDG_RUNTIME_DIR/velox/velox.sock, or /run/user//velox/velox.sock when the env var +// is unset. Mirrors daemon/src/rpc/runtime_dir.cpp; kept in sync by being trivial. +std::string default_socket_path(); + +} // namespace velox::cli diff --git a/cli/src/main.cpp b/cli/src/main.cpp new file mode 100644 index 0000000..ee1fad5 --- /dev/null +++ b/cli/src/main.cpp @@ -0,0 +1,179 @@ +// velox — the command-line client for veloxd. +// +// velox add [--dir D] [--out NAME] [--segments N] [--json] +// velox ls [--json] +// velox pause ... velox resume ... +// velox rm ... [--delete-file] +// +// Exit codes: 0 ok, 1 daemon returned an error, 2 usage error, 3 cannot reach the daemon. + +#include +#include +#include +#include +#include + +#include + +#include "client.hpp" + +namespace { + +using nlohmann::json; +using velox::cli::CallError; +using velox::cli::Client; + +constexpr int kOk = 0; +constexpr int kRpcError = 1; +constexpr int kUsage = 2; +constexpr int kNoDaemon = 3; + +struct Args { + std::vector positional; + bool json = false; + bool delete_file = false; + std::string dir; + std::string out; + long segments = 0; +}; + +[[noreturn]] void usage(int code) { + std::fprintf(code == kOk ? stdout : stderr, + "usage: velox [options]\n\n" + " add [--dir DIR] [--out NAME] [--segments N]\n" + " ls\n" + " pause ...\n" + " resume ...\n" + " rm ... [--delete-file]\n\n" + " --json print the raw JSON-RPC result\n"); + std::exit(code); +} + +Args parse_args(int argc, char** argv) { + Args a; + for (int i = 2; i < argc; ++i) { + const std::string_view arg = argv[i]; + if (arg == "--json") { + a.json = true; + } else if (arg == "--delete-file") { + a.delete_file = true; + } else if (arg == "--dir" && i + 1 < argc) { + a.dir = argv[++i]; + } else if (arg == "--out" && i + 1 < argc) { + a.out = argv[++i]; + } else if (arg == "--segments" && i + 1 < argc) { + a.segments = std::strtol(argv[++i], nullptr, 10); + } else if (arg == "-h" || arg == "--help") { + usage(kOk); + } else if (!arg.empty() && arg.front() == '-') { + std::fprintf(stderr, "velox: unknown option %s\n", argv[i]); + usage(kUsage); + } else { + a.positional.emplace_back(arg); + } + } + return a; +} + +int report_error(const CallError& e, bool as_json) { + if (as_json) { + json j = {{"error", {{"code", e.code}, {"message", e.message}}}}; + if (!e.data.is_null()) j["error"]["data"] = e.data; + std::printf("%s\n", j.dump(2).c_str()); + } else { + std::fprintf(stderr, "velox: %s\n", e.message.c_str()); + } + return e.code == CallError::kConnect ? kNoDaemon : kRpcError; +} + +void print_task_table(const json& items) { + std::printf("%-38s %-10s %-9s %s\n", "ID", "STATE", "PROGRESS", "NAME"); + for (const auto& t : items) { + const std::string id = t.value("taskId", ""); + const std::string state = t.value("state", ""); + const std::string name = t.value("filename", ""); + const long long total = t.value("sizeBytes", 0LL); + const long long done = t.value("downloadedBytes", 0LL); + char pct[12] = "-"; + if (total > 0) std::snprintf(pct, sizeof(pct), "%lld%%", done * 100 / total); + std::printf("%-38s %-10s %-9s %s\n", id.c_str(), state.c_str(), pct, name.c_str()); + } +} + +int cmd_ls(Client& c, const Args& a) { + auto r = c.call("download.list", json::object()); + if (!r) return report_error(r.error(), a.json); + if (a.json) { + std::printf("%s\n", r->dump(2).c_str()); + return kOk; + } + const auto& items = r->contains("items") ? r->at("items") : json::array(); + if (items.empty()) { + std::printf("no downloads\n"); + return kOk; + } + print_task_table(items); + return kOk; +} + +int cmd_add(Client& c, const Args& a) { + if (a.positional.empty()) { + std::fprintf(stderr, "velox add: a URL is required\n"); + return kUsage; + } + json params = {{"url", a.positional.front()}}; + if (!a.dir.empty()) params["saveDir"] = a.dir; + if (!a.out.empty()) params["filename"] = a.out; + if (a.segments > 0) params["segments"] = a.segments; + + auto r = c.call("download.add", params); + if (!r) return report_error(r.error(), a.json); + if (a.json) { + std::printf("%s\n", r->dump(2).c_str()); + } else { + std::printf("added %s\n", r->value("taskId", "?").c_str()); + } + return kOk; +} + +int cmd_bulk(Client& c, const Args& a, const char* method) { + if (a.positional.empty()) { + std::fprintf(stderr, "velox: at least one task id is required\n"); + return kUsage; + } + json params = {{"taskIds", a.positional}}; + if (std::string_view(method) == "download.remove" && a.delete_file) params["deleteFile"] = true; + + auto r = c.call(method, params); + if (!r) return report_error(r.error(), a.json); + if (a.json) { + std::printf("%s\n", r->dump(2).c_str()); + } else { + std::printf("ok\n"); + } + return kOk; +} + +} // namespace + +int main(int argc, char** argv) { + if (argc < 2) usage(kUsage); + const std::string command = argv[1]; + if (command == "-h" || command == "--help") usage(kOk); + + const Args args = parse_args(argc, argv); + + Client client; + if (const auto err = client.connect()) { + return report_error(*err, args.json); + } + + if (command == "ls") return cmd_ls(client, args); + if (command == "add") return cmd_add(client, args); + if (command == "pause") return cmd_bulk(client, args, "download.pause"); + if (command == "resume") return cmd_bulk(client, args, "download.resume"); + if (command == "rm") return cmd_bulk(client, args, "download.remove"); + + std::fprintf(stderr, "velox: unknown command '%s'\n", command.c_str()); + usage(kUsage); +} diff --git a/cli/tests/CMakeLists.txt b/cli/tests/CMakeLists.txt new file mode 100644 index 0000000..15165f2 --- /dev/null +++ b/cli/tests/CMakeLists.txt @@ -0,0 +1,13 @@ +# CLI integration test: a real in-process UdsServer on a temp socket, the real Client +# against it. Links veloxd_rpc for the server half. + +add_executable(velox_client_test client_test.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/client.cpp) +target_include_directories(velox_client_test PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/../src + ${CMAKE_CURRENT_SOURCE_DIR}/../../daemon/tests +) +target_compile_features(velox_client_test PRIVATE cxx_std_23) +target_compile_options(velox_client_test PRIVATE -Wall -Wextra -Wpedantic -Werror) +target_link_libraries(velox_client_test PRIVATE veloxd_rpc velox::proto nlohmann_json::nlohmann_json) +add_test(NAME velox.client COMMAND velox_client_test) +set_tests_properties(velox.client PROPERTIES TIMEOUT 30) diff --git a/cli/tests/client_test.cpp b/cli/tests/client_test.cpp new file mode 100644 index 0000000..0f1f1a7 --- /dev/null +++ b/cli/tests/client_test.cpp @@ -0,0 +1,78 @@ +// The CLI's Client against a real in-process daemon RPC server. + +#include +#include + +#include +#include +#include + +#include "check.hpp" +#include "client.hpp" +#include "rpc/dispatcher.hpp" +#include "rpc/event_loop.hpp" +#include "rpc/uds_server.hpp" + +namespace rpc = velox::daemon::rpc; +using velox::cli::CallError; +using velox::cli::Client; + +void run() { + // connect() with no daemon -> a kConnect error, not a crash. + { + ::setenv("XDG_RUNTIME_DIR", "/tmp/velox-cli-test-nonexistent-xyz", 1); + Client c; + auto err = c.connect(); + CHECK(err.has_value()); + if (err) CHECK_EQ(err->code, CallError::kConnect); + } + + // Point XDG_RUNTIME_DIR at a fresh temp dir; the client derives + // /velox/velox.sock and the server binds exactly that. + char tmpl[] = "/tmp/velox-cli-test-XXXXXX"; + const char* xdg = ::mkdtemp(tmpl); + CHECK(xdg != nullptr); + if (xdg == nullptr) return; + ::setenv("XDG_RUNTIME_DIR", xdg, 1); + const std::string velox_dir = std::string(xdg) + "/velox"; + ::mkdir(velox_dir.c_str(), 0700); + const std::string server_sock = velox_dir + "/velox.sock"; + + rpc::EventLoop loop; + rpc::VeloxDispatcher dispatcher; + rpc::UdsServer server(loop, dispatcher, server_sock); + const auto ec = server.start(); + CHECK(!ec); + if (ec) return; + std::thread th([&loop] { loop.run(); }); + + { + Client c; + auto err = c.connect(); + CHECK(!err.has_value()); + if (err) { + loop.stop(); + th.join(); + return; + } + CHECK_EQ(c.hello_result().value("transport", ""), std::string("uds")); + + auto ls = c.call("download.list", nlohmann::json::object()); + CHECK(ls.has_value()); + if (ls) { + CHECK_EQ(ls->value("total", -1), 0); + CHECK(ls->at("items").is_array()); + } + + // A not-yet-implemented method surfaces the daemon's error, not a transport error. + auto add = c.call("download.add", {{"url", "https://example.com/x"}}); + CHECK(!add.has_value()); + if (!add) CHECK_EQ(add.error().code, -32603); + } + + loop.stop(); + th.join(); + ::unlink(server_sock.c_str()); +} + +TEST_MAIN() From 4b279e82718067d2af86e155e8bc9835ed34c51d Mon Sep 17 00:00:00 2001 From: sami Date: Thu, 10 Sep 2026 15:15:13 +0400 Subject: [PATCH 3/7] =?UTF-8?q?daemon:=20store/=20=E2=80=94=20SQLite=20WAL?= =?UTF-8?q?=20schema=20+=20forward-only=20migrator=20(build=20step=203)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The daemon's persistent state. SQLite in WAL mode, foreign keys on, 5 s busy timeout so a writer waits rather than SQLITE_BUSY under the RPC loop. - store/sqlite — RAII Db/Stmt over the C API; errors returned as DbResult (std::expected), never thrown — the RPC loop must not unwind. transaction() helper: BEGIN / fn / COMMIT, ROLLBACK on error. - store/migrations/0001_initial.sql — the eight tables from the brief: settings, categories, queues, tasks, segments, rules, history, pairings. Notable choices: * tasks columns project onto proto TaskSummary with no computation; requested vs effective segments/buffer split per ADR 0010/0012; pause_reason column per ADR 0013. * segments end_byte is NOT constrained >= 0 so a whole-file zero-length download is one row with end_byte = -1 (ADR 0010 B3a). * pairings stores only token_sha256 — the plaintext token is returned once from session.pair and never persisted (CLAUDE.md §4). * indices on tasks(state), (category_id), (queue_id, queue_position), (created_at), (completed_at) for the "1000 tasks, download.list under 50 ms" DoD. * six built-in categories + a Main queue seeded. - store/migrations — runs every embedded migration past PRAGMA user_version, each in its own transaction, forward-only. SQL files are embedded at build time by cmake/embed_migrations.cmake. Test veloxd.store_migrations (ASan+UBSan and TSan clean): fresh DB -> head, all tables present, seed rows, FK cascade (segment orphan rejected, task delete cascades), the end_byte=-1 zero-length case, idempotent re-run, and forward-only from every released user_version. Also: daemon/docs/proto-requests-m1.md — P1 marked landed on lane/proto as 1.4.0 (HandlerError/HandlerResult), to be adopted in rpc/ once that merges to main; P2 resolved. Not linked into the running daemon yet — the store is wired to the dispatcher when download.add/list/get get real bodies, next. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Upd9WhG9oppieig5nRDLig --- daemon/CMakeLists.txt | 31 +++- daemon/cmake/embed_migrations.cmake | 52 ++++++ daemon/docs/proto-requests-m1.md | 13 ++ daemon/src/store/migrations.cpp | 44 +++++ daemon/src/store/migrations.hpp | 38 ++++ daemon/src/store/migrations/.gitkeep | 0 daemon/src/store/migrations/0001_initial.sql | 176 +++++++++++++++++++ daemon/src/store/sqlite.cpp | 146 +++++++++++++++ daemon/src/store/sqlite.hpp | 109 ++++++++++++ daemon/tests/CMakeLists.txt | 5 + daemon/tests/store_migrations_test.cpp | 118 +++++++++++++ 11 files changed, 731 insertions(+), 1 deletion(-) create mode 100644 daemon/cmake/embed_migrations.cmake create mode 100644 daemon/src/store/migrations.cpp create mode 100644 daemon/src/store/migrations.hpp delete mode 100644 daemon/src/store/migrations/.gitkeep create mode 100644 daemon/src/store/migrations/0001_initial.sql create mode 100644 daemon/src/store/sqlite.cpp create mode 100644 daemon/src/store/sqlite.hpp create mode 100644 daemon/tests/store_migrations_test.cpp diff --git a/daemon/CMakeLists.txt b/daemon/CMakeLists.txt index 6191528..d0e442b 100644 --- a/daemon/CMakeLists.txt +++ b/daemon/CMakeLists.txt @@ -11,6 +11,35 @@ if(NOT TARGET nlohmann_json::nlohmann_json) find_package(nlohmann_json 3.11 REQUIRED) endif() find_package(Threads REQUIRED) +find_package(SQLite3 REQUIRED) + +# --- generated: migrations_embedded.hpp from src/store/migrations/*.sql --------------- +set(_mig_dir ${CMAKE_CURRENT_SOURCE_DIR}/src/store/migrations) +set(_mig_hdr ${CMAKE_CURRENT_BINARY_DIR}/generated/migrations_embedded.hpp) +file(GLOB _mig_srcs ${_mig_dir}/*.sql) +add_custom_command( + OUTPUT ${_mig_hdr} + COMMAND ${CMAKE_COMMAND} -DMIG_DIR=${_mig_dir} -DOUT=${_mig_hdr} + -P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/embed_migrations.cmake + DEPENDS ${_mig_srcs} ${CMAKE_CURRENT_SOURCE_DIR}/cmake/embed_migrations.cmake + COMMENT "Embedding SQL migrations" + VERBATIM) +add_custom_target(veloxd_migrations_hdr DEPENDS ${_mig_hdr}) + +# --- veloxd_store — SQLite store + migrations ----------------------------------------- +add_library(veloxd_store STATIC + src/store/sqlite.cpp + src/store/migrations.cpp + ${_mig_hdr} +) +add_library(velox::daemon_store ALIAS veloxd_store) +target_include_directories(veloxd_store + PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/src + PRIVATE ${CMAKE_CURRENT_BINARY_DIR}/generated +) +target_compile_features(veloxd_store PUBLIC cxx_std_23) +target_compile_options(veloxd_store PRIVATE -Wall -Wextra -Wpedantic -Werror) +target_link_libraries(veloxd_store PUBLIC SQLite::SQLite3) # --- veloxd_rpc — the server library ---------------------------------------------------- add_library(veloxd_rpc STATIC @@ -34,7 +63,7 @@ target_link_libraries(veloxd_rpc add_executable(veloxd src/main.cpp) target_compile_features(veloxd PRIVATE cxx_std_23) target_compile_options(veloxd PRIVATE -Wall -Wextra -Wpedantic -Werror) -target_link_libraries(veloxd PRIVATE veloxd_rpc) +target_link_libraries(veloxd PRIVATE veloxd_rpc veloxd_store) if(VELOX_BUILD_TESTS AND EXISTS ${CMAKE_CURRENT_SOURCE_DIR}/tests/CMakeLists.txt) add_subdirectory(tests) diff --git a/daemon/cmake/embed_migrations.cmake b/daemon/cmake/embed_migrations.cmake new file mode 100644 index 0000000..e018260 --- /dev/null +++ b/daemon/cmake/embed_migrations.cmake @@ -0,0 +1,52 @@ +# Generates migrations_embedded.hpp from daemon/src/store/migrations/*.sql. +# +# cmake -DMIG_DIR= -DOUT= -P embed_migrations.cmake +# +# Each NNNN_name.sql becomes a Migration{ version = NNNN, name = "NNNN_name", sql = R"..." }. +# The delimiter for the raw string literal is chosen to not collide with the file body. + +file(GLOB _sql_files "${MIG_DIR}/*.sql") +list(SORT _sql_files) + +set(_entries "") +foreach(_f ${_sql_files}) + get_filename_component(_stem "${_f}" NAME_WE) # 0001_initial + string(REGEX MATCH "^([0-9]+)_" _m "${_stem}") + if(NOT _m) + message(FATAL_ERROR "migration file '${_f}' does not start with NNNN_") + endif() + string(REGEX REPLACE "^0*([0-9]+)_.*$" "\\1" _ver "${_stem}") + + file(READ "${_f}" _body) + # Pick a raw-string delimiter guaranteed absent from the body. + set(_delim "MIGSQL") + while(_body MATCHES "\\)${_delim}\"") + set(_delim "${_delim}X") + endwhile() + + string(APPEND _entries + " Migration{ ${_ver}, \"${_stem}\", R\"${_delim}(\n${_body}\n)${_delim}\" },\n") +endforeach() + +list(LENGTH _sql_files _count) + +set(_out "// GENERATED by embed_migrations.cmake — do not edit. Source: src/store/migrations/*.sql +#pragma once +#include +#include \"store/migrations.hpp\" + +namespace velox::daemon::store { + +inline constexpr std::array kEmbeddedMigrations = {{ +${_entries}}}; + +} // namespace velox::daemon::store +") + +if(EXISTS "${OUT}") + file(READ "${OUT}" _existing) + if(_existing STREQUAL "${_out}") + return() # unchanged — do not rewrite, keeps the build stable + endif() +endif() +file(WRITE "${OUT}" "${_out}") diff --git a/daemon/docs/proto-requests-m1.md b/daemon/docs/proto-requests-m1.md index 0833155..383510e 100644 --- a/daemon/docs/proto-requests-m1.md +++ b/daemon/docs/proto-requests-m1.md @@ -8,6 +8,19 @@ it needs a version note and a regen, not a silent change. --- +## Status + +- **P1 — landed** on `lane/proto` as `contracts/` **1.4.0** (commit `5e3e215`), as the + `HandlerError` / `HandlerResult` sketch below. Wire is byte-identical; C++-binding + bump only. `rpc/` adopts it (the predicted `Result` → `HandlerResult` swap on the + `on_*` overrides) **once `lane/proto` merges to `main`** — not against the unmerged + branch. `uds_roundtrip`'s `-32603`-collapse guard flips to `-32010` in the same change. +- **P2 — resolved.** `session.hello.version-mismatch`'s `data.expected` is now `$any`; + the error-fixture compare is on `code` only, so `rpc/` echoes `kProtocolVersion` there. + PROTO's writeup: `contracts/proto-answers-daemon-m1.md`. + +--- + ## P1. The generated `Dispatcher` has no error channel below `-32603` — **blocking a conformant server** `velox::proto::Dispatcher`'s 39 methods each return `Result` = diff --git a/daemon/src/store/migrations.cpp b/daemon/src/store/migrations.cpp new file mode 100644 index 0000000..1732811 --- /dev/null +++ b/daemon/src/store/migrations.cpp @@ -0,0 +1,44 @@ +#include "store/migrations.hpp" + +#include + +// Generated at build time from store/migrations/*.sql by embed_migrations.cmake. +#include "migrations_embedded.hpp" + +namespace velox::daemon::store { + +std::span embedded_migrations() { + return {kEmbeddedMigrations.data(), kEmbeddedMigrations.size()}; +} + +DbResult migrate_to_head(Db& db) { + const auto all = embedded_migrations(); + + MigrationOutcome out; + out.from_version = db.user_version(); + out.to_version = out.from_version; + if (out.from_version < 0) { + return std::unexpected(DbError{0, "could not read PRAGMA user_version"}); + } + + for (const auto& m : all) { + if (m.version <= out.from_version) continue; + + // Each migration is one transaction: a failure half-way leaves user_version and + // the schema exactly where they were. + auto r = db.transaction([&]() -> DbResult { + if (auto e = db.exec(m.sql); !e) return e; + return db.set_user_version(m.version); + }); + if (!r) { + return std::unexpected(DbError{r.error().code, "migration " + std::string(m.name) + + " failed: " + r.error().message}); + } + out.to_version = m.version; + ++out.applied; + } + + return out; +} + +} // namespace velox::daemon::store diff --git a/daemon/src/store/migrations.hpp b/daemon/src/store/migrations.hpp new file mode 100644 index 0000000..5d9e7c6 --- /dev/null +++ b/daemon/src/store/migrations.hpp @@ -0,0 +1,38 @@ +#pragma once + +// The schema migrator. Numbered SQL files in store/migrations/ are embedded at build time +// (see embed_migrations.cmake). On startup the daemon calls migrate_to_head(db): every +// migration whose version exceeds PRAGMA user_version is applied in order, each in its own +// transaction, and user_version is advanced to match. +// +// Forward-only: a released migration is immutable. The forward-only test in +// daemon/tests replays from every prior released user_version to head. + +#include +#include +#include + +#include "store/sqlite.hpp" + +namespace velox::daemon::store { + +struct Migration { + std::int64_t version; // 1, 2, 3, ... ; matches the NNNN_ prefix + std::string_view name; // e.g. "0001_initial" + std::string_view sql; // the file body +}; + +// The embedded set, sorted by version ascending. Defined in the generated header. +std::span embedded_migrations(); + +struct MigrationOutcome { + std::int64_t from_version = 0; + std::int64_t to_version = 0; + int applied = 0; +}; + +// Apply every migration newer than db.user_version(). A no-op (applied == 0) when the DB +// is already at or beyond the highest embedded version. +DbResult migrate_to_head(Db& db); + +} // namespace velox::daemon::store diff --git a/daemon/src/store/migrations/.gitkeep b/daemon/src/store/migrations/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/daemon/src/store/migrations/0001_initial.sql b/daemon/src/store/migrations/0001_initial.sql new file mode 100644 index 0000000..61f5eb2 --- /dev/null +++ b/daemon/src/store/migrations/0001_initial.sql @@ -0,0 +1,176 @@ +-- Migration 0001 — initial schema. +-- +-- Applied when PRAGMA user_version < 1. The migrator wraps this file in one transaction +-- and sets user_version = 1 on success. Forward-only: never edit a released migration, +-- add 0002_*.sql instead (AGENT-DAEMON.md build step 3). +-- +-- Conventions: +-- * ids are lowercase UUID text, except the built-in rows below. +-- * timestamps are RFC 3339 UTC strings ("2026-09-10T14:55:02Z") — same on the wire, +-- so projection to TaskSummary is a copy. +-- * JSON-valued columns hold a TEXT document; SQLite's json1 validates on read where +-- it matters. Marked "-- json" below. +-- * credentials NEVER live here (CLAUDE.md §4) — the Secret Service holds those. + +-- --- settings : the whole config bag, one row per SettingKey -------------------------- +CREATE TABLE settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL -- json: the value as it appears in the Settings schema +) WITHOUT ROWID; + +-- --- categories : folder + extension routing ---------------------------------------- +CREATE TABLE categories ( + category_id TEXT PRIMARY KEY, + name TEXT NOT NULL, + save_dir TEXT NOT NULL, + extensions TEXT NOT NULL DEFAULT '[]', -- json array of lowercase extensions, no dot + builtin INTEGER NOT NULL DEFAULT 0 -- 1 = cannot be deleted (category.remove -> -32602) +) WITHOUT ROWID; + +INSERT INTO categories (category_id, name, save_dir, extensions, builtin) VALUES + ('general', 'General', '~/Downloads', '[]', 1), + ('programs', 'Programs', '~/Downloads/Programs', '["exe","msi","deb","rpm","dmg","appimage","iso","zip","tar","gz","xz","7z"]', 1), + ('video', 'Video', '~/Downloads/Video', '["mp4","mkv","webm","avi","mov","flv","m4v","ts"]', 1), + ('audio', 'Audio', '~/Downloads/Audio', '["mp3","flac","aac","ogg","opus","wav","m4a"]', 1), + ('documents','Documents', '~/Downloads/Documents', '["pdf","doc","docx","xls","xlsx","ppt","pptx","odt","epub"]', 1), + ('images', 'Images', '~/Downloads/Images', '["jpg","jpeg","png","gif","webp","svg","bmp","tiff"]', 1); + +-- --- queues : ordered runs with their own concurrency cap --------------------------- +CREATE TABLE queues ( + queue_id TEXT PRIMARY KEY, + name TEXT NOT NULL, + state TEXT NOT NULL DEFAULT 'stopped' -- 'running' | 'stopped' + CHECK (state IN ('running','stopped')), + max_concurrent INTEGER NOT NULL DEFAULT 2 CHECK (max_concurrent BETWEEN 1 AND 32), + on_complete TEXT NOT NULL DEFAULT 'nothing' -- 'nothing'|'exit'|'shutdown'|'hangup' + CHECK (on_complete IN ('nothing','exit','shutdown','hangup')), + schedule TEXT -- json Schedule, or NULL for manual +) WITHOUT ROWID; + +INSERT INTO queues (queue_id, name, state, max_concurrent) VALUES + ('main', 'Main Queue', 'stopped', 4); + +-- --- tasks : the download list ----------------------------------------------------- +-- Column set is chosen so a row projects onto proto TaskSummary with no computation +-- beyond reading segments/history for the detail view. +CREATE TABLE tasks ( + task_id TEXT PRIMARY KEY, + url TEXT NOT NULL, -- as supplied + effective_url TEXT, -- after redirects; NULL until first probe + filename TEXT NOT NULL DEFAULT '', + save_dir TEXT NOT NULL, -- absolute, canonicalized, inside an allowed root + category_id TEXT REFERENCES categories(category_id) ON DELETE SET NULL, + queue_id TEXT REFERENCES queues(queue_id) ON DELETE SET NULL, + queue_position INTEGER, -- NULL unless queued; run order within the queue + + state TEXT NOT NULL DEFAULT 'new' + CHECK (state IN ('new','probing','queued','connecting','downloading','paused', + 'retry_wait','assembling','verifying','complete','failed','cancelled')), + -- ADR 0013: why a paused task is paused. NULL unless state='paused'. 'auto' means CORE + -- entered it (auth_required/server_file_changed/disk_full); the code is in error_code. + pause_reason TEXT CHECK (pause_reason IN + ('user','schedule','queue_stopped','admission_reconcile','auto')), + + size_bytes INTEGER, -- NULL when the server reported no length + downloaded_bytes INTEGER NOT NULL DEFAULT 0, + resumable INTEGER NOT NULL DEFAULT 0, + + -- Requested vs effective, per ADR 0010 / ADR 0012. Requested values come from the + -- DownloadSpec; effective values are written by the engine as it runs. + req_segments INTEGER, -- DownloadSpec.segments (NULL = use setting) + eff_segments INTEGER NOT NULL DEFAULT 0,-- TaskSummary.segments (in use right now) + req_buffer_bytes INTEGER, + eff_buffer_bytes INTEGER, -- TaskDetail.effectiveBufferBytes + + start_mode TEXT NOT NULL DEFAULT 'auto' + CHECK (start_mode IN ('auto','now','queue','manual')), + description TEXT, + + -- Validators, kept for If-Range resume revalidation (docs/04 §5). + etag TEXT, + last_modified TEXT, + content_type TEXT, + + checksum_algo TEXT CHECK (checksum_algo IN ('md5','sha1','sha256','sha512')), + checksum_value TEXT, + + -- proto TaskError, flattened. Set on failed / retry_wait, and on an auto-pause. + error_code TEXT, -- TaskErrorCode string + error_message TEXT, + error_http_status INTEGER, + error_retryable INTEGER, + error_attempt INTEGER, + error_next_retry_at TEXT, + + created_at TEXT NOT NULL, + last_try_at TEXT, + completed_at TEXT +) STRICT; + +-- download.list filters/sorts in the daemon (brief: never materialize 100k rows for 40). +-- These cover the common filter columns and both default sorts. +CREATE INDEX idx_tasks_state ON tasks(state); +CREATE INDEX idx_tasks_category ON tasks(category_id); +CREATE INDEX idx_tasks_queue_order ON tasks(queue_id, queue_position); +CREATE INDEX idx_tasks_created ON tasks(created_at); +CREATE INDEX idx_tasks_completed ON tasks(completed_at); + +-- --- segments : per-connection byte ranges for one task --------------------------- +-- Inclusive ranges [start_byte, end_byte], matching HTTP Range and ADR 0010. A whole-file +-- zero-length download is one row with end_byte = start_byte - 1 = -1 (ADR 0010 B3a), so +-- end_byte is not constrained to >= 0. +CREATE TABLE segments ( + task_id TEXT NOT NULL REFERENCES tasks(task_id) ON DELETE CASCADE, + idx INTEGER NOT NULL, + start_byte INTEGER NOT NULL, + end_byte INTEGER NOT NULL, + completed_bytes INTEGER NOT NULL DEFAULT 0, + state TEXT NOT NULL DEFAULT 'connecting' + CHECK (state IN ('connecting','downloading','stalled','complete','failed')), + PRIMARY KEY (task_id, idx) +) STRICT, WITHOUT ROWID; + +-- --- rules : the routing / capture rules engine table --------------------------- +CREATE TABLE rules ( + rule_id TEXT PRIMARY KEY, + priority INTEGER NOT NULL, -- lower runs first; rules.list returns priority order + enabled INTEGER NOT NULL DEFAULT 1, + match TEXT NOT NULL, -- json: the match clause (host/ext/size/mime/...) + action TEXT NOT NULL -- json: capture decision + category + queue + start mode +) WITHOUT ROWID; + +CREATE INDEX idx_rules_priority ON rules(priority); + +-- --- history : completed and removed tasks, for the History view -------------------- +-- A task leaving the list (complete, or removed by the user) drops a snapshot here so the +-- main tasks table stays the size of the active list. +CREATE TABLE history ( + history_id INTEGER PRIMARY KEY AUTOINCREMENT, + task_id TEXT NOT NULL, + filename TEXT NOT NULL, + url TEXT NOT NULL, + save_dir TEXT NOT NULL, + size_bytes INTEGER, + final_state TEXT NOT NULL, -- 'complete' | 'cancelled' | 'failed' + category_id TEXT, + finished_at TEXT NOT NULL, + snapshot TEXT NOT NULL -- json: the full TaskSummary at the time it left +); + +CREATE INDEX idx_history_finished ON history(finished_at); +CREATE INDEX idx_history_task ON history(task_id); + +-- --- pairings : WebSocket transport tokens, HASHED (docs/05 §4, CLAUDE.md §4) -------- +-- The plaintext token is returned to the extension exactly once, from session.pair, and +-- never stored. token_sha256 is the lookup key on every subsequent connect. +CREATE TABLE pairings ( + pairing_id TEXT PRIMARY KEY, + token_sha256 TEXT NOT NULL UNIQUE, -- hex SHA-256 of the 256-bit token + origin TEXT NOT NULL, -- moz-extension://, verified on the WS upgrade + label TEXT NOT NULL DEFAULT '', -- human-readable, shown in Options -> Unpair + created_at TEXT NOT NULL, + last_seen_at TEXT, + revoked_at TEXT -- non-NULL once unpaired; kept for the audit trail +) WITHOUT ROWID; + +CREATE INDEX idx_pairings_origin ON pairings(origin); diff --git a/daemon/src/store/sqlite.cpp b/daemon/src/store/sqlite.cpp new file mode 100644 index 0000000..136d60e --- /dev/null +++ b/daemon/src/store/sqlite.cpp @@ -0,0 +1,146 @@ +#include "store/sqlite.hpp" + +#include + +#include + +namespace velox::daemon::store { + +// --- Db ------------------------------------------------------------------------------ + +Db::~Db() { + if (db_ != nullptr) sqlite3_close(db_); +} + +Db::Db(Db&& o) noexcept : db_(std::exchange(o.db_, nullptr)) {} + +Db& Db::operator=(Db&& o) noexcept { + if (this != &o) { + if (db_ != nullptr) sqlite3_close(db_); + db_ = std::exchange(o.db_, nullptr); + } + return *this; +} + +DbError Db::last_error() const { + return DbError{sqlite3_extended_errcode(db_), sqlite3_errmsg(db_)}; +} + +DbResult Db::open(const std::string& path) { + sqlite3* handle = nullptr; + const int rc = sqlite3_open_v2( + path.c_str(), &handle, SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE | SQLITE_OPEN_NOMUTEX, + nullptr); + if (rc != SQLITE_OK) { + DbError e{rc, handle != nullptr ? sqlite3_errmsg(handle) : "sqlite3_open_v2 failed"}; + if (handle != nullptr) sqlite3_close(handle); + return std::unexpected(std::move(e)); + } + + Db db(handle); + // WAL for crash-safe concurrent readers (docs/01 §1). busy_timeout so a writer waits + // rather than returning SQLITE_BUSY under the RPC loop. foreign_keys is per-connection. + for (const char* pragma : {"PRAGMA journal_mode=WAL", "PRAGMA synchronous=NORMAL", + "PRAGMA foreign_keys=ON", "PRAGMA busy_timeout=5000"}) { + if (auto r = db.exec(pragma); !r) return std::unexpected(r.error()); + } + return db; +} + +DbResult Db::exec(std::string_view sql) { + char* err = nullptr; + const int rc = sqlite3_exec(db_, std::string(sql).c_str(), nullptr, nullptr, &err); + if (rc != SQLITE_OK) { + DbError e{rc, err != nullptr ? err : sqlite3_errmsg(db_)}; + sqlite3_free(err); + return std::unexpected(std::move(e)); + } + return {}; +} + +DbResult Db::prepare(std::string_view sql) { + sqlite3_stmt* s = nullptr; + const int rc = + sqlite3_prepare_v2(db_, sql.data(), static_cast(sql.size()), &s, nullptr); + if (rc != SQLITE_OK) return std::unexpected(last_error()); + return Stmt(db_, s); +} + +std::int64_t Db::user_version() { + auto st = prepare("PRAGMA user_version"); + if (!st) return -1; + auto row = st->step(); + if (!row || !*row) return -1; + return st->column_int(0); +} + +DbResult Db::set_user_version(std::int64_t v) { + // PRAGMA does not accept a bound parameter; the value is our own integer. + return exec("PRAGMA user_version=" + std::to_string(v)); +} + +// --- Stmt ---------------------------------------------------------------------------- + +Stmt::~Stmt() { + if (stmt_ != nullptr) sqlite3_finalize(stmt_); +} + +Stmt::Stmt(Stmt&& o) noexcept + : db_(std::exchange(o.db_, nullptr)), stmt_(std::exchange(o.stmt_, nullptr)) {} + +Stmt& Stmt::operator=(Stmt&& o) noexcept { + if (this != &o) { + if (stmt_ != nullptr) sqlite3_finalize(stmt_); + db_ = std::exchange(o.db_, nullptr); + stmt_ = std::exchange(o.stmt_, nullptr); + } + return *this; +} + +DbError Stmt::last_error() const { + return DbError{sqlite3_extended_errcode(db_), sqlite3_errmsg(db_)}; +} + +DbResult Stmt::bind(int i, std::int64_t v) { + if (sqlite3_bind_int64(stmt_, i, v) != SQLITE_OK) return std::unexpected(last_error()); + return {}; +} + +DbResult Stmt::bind(int i, std::string_view v) { + if (sqlite3_bind_text(stmt_, i, v.data(), static_cast(v.size()), SQLITE_TRANSIENT) != + SQLITE_OK) + return std::unexpected(last_error()); + return {}; +} + +DbResult Stmt::bind_null(int i) { + if (sqlite3_bind_null(stmt_, i) != SQLITE_OK) return std::unexpected(last_error()); + return {}; +} + +DbResult Stmt::step() { + const int rc = sqlite3_step(stmt_); + if (rc == SQLITE_ROW) return true; + if (rc == SQLITE_DONE) return false; + return std::unexpected(last_error()); +} + +DbResult Stmt::reset() { + if (sqlite3_reset(stmt_) != SQLITE_OK) return std::unexpected(last_error()); + return {}; +} + +std::int64_t Stmt::column_int(int i) const { return sqlite3_column_int64(stmt_, i); } + +std::string Stmt::column_text(int i) const { + const auto* p = sqlite3_column_text(stmt_, i); + if (p == nullptr) return {}; + return std::string(reinterpret_cast(p), + static_cast(sqlite3_column_bytes(stmt_, i))); +} + +bool Stmt::column_is_null(int i) const { + return sqlite3_column_type(stmt_, i) == SQLITE_NULL; +} + +} // namespace velox::daemon::store diff --git a/daemon/src/store/sqlite.hpp b/daemon/src/store/sqlite.hpp new file mode 100644 index 0000000..c79899d --- /dev/null +++ b/daemon/src/store/sqlite.hpp @@ -0,0 +1,109 @@ +#pragma once + +// A thin RAII wrapper over the SQLite C API — just enough for the store: open in WAL mode, +// run statements, prepare/bind/step. Errors are returned, never thrown (the RPC loop must +// not unwind through an exception). No ORM, no query builder. + +#include +#include +#include +#include +#include + +struct sqlite3; +struct sqlite3_stmt; + +namespace velox::daemon::store { + +struct DbError { + int code = 0; // SQLite result code + std::string message; + + std::string to_string() const { + return message + " (sqlite " + std::to_string(code) + ")"; + } +}; + +template +using DbResult = std::expected; + +class Stmt; + +class Db { +public: + Db() = default; + ~Db(); + Db(Db&&) noexcept; + Db& operator=(Db&&) noexcept; + Db(const Db&) = delete; + Db& operator=(const Db&) = delete; + + // Open (creating if absent) at `path`, set WAL, busy timeout, and foreign_keys=ON. + // ":memory:" is accepted for tests. + static DbResult open(const std::string& path); + + // Run one or more statements with no result rows (DDL, PRAGMA, INSERT without + // returning). Uses sqlite3_exec, so it accepts a multi-statement script. + DbResult exec(std::string_view sql); + + DbResult prepare(std::string_view sql); + + // Convenience: run `fn` between BEGIN and COMMIT; ROLLBACK and propagate on error. + template + DbResult transaction(Fn&& fn) { + if (auto r = exec("BEGIN"); !r) return r; + auto r = std::forward(fn)(); + if (!r) { + exec("ROLLBACK"); // best effort; original error wins + return r; + } + return exec("COMMIT"); + } + + std::int64_t user_version(); + DbResult set_user_version(std::int64_t v); + + sqlite3* raw() const noexcept { return db_; } + explicit operator bool() const noexcept { return db_ != nullptr; } + +private: + explicit Db(sqlite3* db) : db_(db) {} + DbError last_error() const; + + sqlite3* db_ = nullptr; +}; + +// A prepared statement. bind_* are 1-indexed. step() returns true while rows remain. +class Stmt { +public: + Stmt() = default; + ~Stmt(); + Stmt(Stmt&&) noexcept; + Stmt& operator=(Stmt&&) noexcept; + Stmt(const Stmt&) = delete; + Stmt& operator=(const Stmt&) = delete; + + DbResult bind(int i, std::int64_t v); + DbResult bind(int i, std::string_view v); + DbResult bind_null(int i); + + // true: a row is available; false: done. Any error code is surfaced via error(). + DbResult step(); + DbResult reset(); + + std::int64_t column_int(int i) const; + std::string column_text(int i) const; + bool column_is_null(int i) const; + + sqlite3_stmt* raw() const noexcept { return stmt_; } + +private: + friend class Db; + explicit Stmt(sqlite3* db, sqlite3_stmt* s) : db_(db), stmt_(s) {} + DbError last_error() const; + + sqlite3* db_ = nullptr; // borrowed, for error messages + sqlite3_stmt* stmt_ = nullptr; +}; + +} // namespace velox::daemon::store diff --git a/daemon/tests/CMakeLists.txt b/daemon/tests/CMakeLists.txt index 04983f7..fa8ee78 100644 --- a/daemon/tests/CMakeLists.txt +++ b/daemon/tests/CMakeLists.txt @@ -12,3 +12,8 @@ target_link_libraries(veloxd_uds_roundtrip_test PRIVATE veloxd_rpc) target_compile_options(veloxd_uds_roundtrip_test PRIVATE -Wall -Wextra -Wpedantic -Werror) add_test(NAME veloxd.uds_roundtrip COMMAND veloxd_uds_roundtrip_test) set_tests_properties(veloxd.uds_roundtrip PROPERTIES TIMEOUT 30) + +add_executable(veloxd_store_migrations_test store_migrations_test.cpp) +target_link_libraries(veloxd_store_migrations_test PRIVATE veloxd_store) +target_compile_options(veloxd_store_migrations_test PRIVATE -Wall -Wextra -Wpedantic -Werror) +add_test(NAME veloxd.store_migrations COMMAND veloxd_store_migrations_test) diff --git a/daemon/tests/store_migrations_test.cpp b/daemon/tests/store_migrations_test.cpp new file mode 100644 index 0000000..50971aa --- /dev/null +++ b/daemon/tests/store_migrations_test.cpp @@ -0,0 +1,118 @@ +// The migrator: fresh DB -> head, idempotent re-run, and forward-only from every released +// user_version (M1 DoD: "a forward-only test from every released schema version"). + +#include + +#include "check.hpp" +#include "store/migrations.hpp" +#include "store/sqlite.hpp" + +using namespace velox::daemon::store; + +namespace { + +std::int64_t head_version() { + std::int64_t v = 0; + for (const auto& m : embedded_migrations()) v = std::max(v, m.version); + return v; +} + +bool table_exists(Db& db, const char* name) { + auto st = db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1"); + if (!st) return false; + if (!st->bind(1, std::string_view(name))) return false; + auto row = st->step(); + return row && *row; +} + +std::int64_t count(Db& db, const char* sql) { + auto st = db.prepare(sql); + if (!st) return -1; + auto row = st->step(); + if (!row || !*row) return -1; + return st->column_int(0); +} + +} // namespace + +void run() { + const std::int64_t head = head_version(); + CHECK(head >= 1); + + // --- fresh in-memory DB migrates cleanly to head -------------------------------- + { + auto db = Db::open(":memory:"); + CHECK(db.has_value()); + if (!db) return; + CHECK_EQ(db->user_version(), 0); + + auto out = migrate_to_head(*db); + CHECK(out.has_value()); + if (out) { + CHECK_EQ(out->from_version, 0); + CHECK_EQ(out->to_version, head); + CHECK_EQ(static_cast(out->applied), head); + } + CHECK_EQ(db->user_version(), head); + + for (const char* t : {"settings", "categories", "queues", "tasks", "segments", + "rules", "history", "pairings"}) { + CHECK(table_exists(*db, t)); + } + // Seed rows the initial migration inserts. + CHECK_EQ(count(*db, "SELECT count(*) FROM categories WHERE builtin=1"), 6); + CHECK_EQ(count(*db, "SELECT count(*) FROM queues"), 1); + + // FK + cascade wired: a segment for a missing task is rejected; deleting a task + // takes its segments with it. + CHECK(db->exec("INSERT INTO tasks(task_id,url,save_dir,created_at) " + "VALUES('t1','http://x','/tmp','2026-09-10T00:00:00Z')") + .has_value()); + CHECK(db->exec("INSERT INTO segments(task_id,idx,start_byte,end_byte) " + "VALUES('t1',0,0,99)") + .has_value()); + CHECK(!db->exec("INSERT INTO segments(task_id,idx,start_byte,end_byte) " + "VALUES('nope',0,0,99)") + .has_value()); + CHECK(db->exec("DELETE FROM tasks WHERE task_id='t1'").has_value()); + CHECK_EQ(count(*db, "SELECT count(*) FROM segments"), 0); + + // A whole-file zero-length download: one segment, end_byte = -1 (ADR 0010 B3a). + CHECK(db->exec("INSERT INTO tasks(task_id,url,save_dir,created_at,size_bytes) " + "VALUES('z','http://x','/tmp','2026-09-10T00:00:00Z',0)") + .has_value()); + CHECK(db->exec("INSERT INTO segments(task_id,idx,start_byte,end_byte) " + "VALUES('z',0,0,-1)") + .has_value()); + } + + // --- re-running the migrator on an at-head DB is a no-op ------------------------ + { + auto db = Db::open(":memory:"); + CHECK(db.has_value()); + (void)migrate_to_head(*db); + auto again = migrate_to_head(*db); + CHECK(again.has_value()); + if (again) { + CHECK_EQ(again->applied, 0); + CHECK_EQ(again->to_version, head); + } + } + + // --- forward-only: from every released version [0 .. head-1], reach head -------- + for (std::int64_t start = 0; start < head; ++start) { + auto db = Db::open(":memory:"); + CHECK(db.has_value()); + if (!db) continue; + CHECK(db->set_user_version(start).has_value()); + auto out = migrate_to_head(*db); + CHECK(out.has_value()); + if (out) { + CHECK_EQ(out->from_version, start); + CHECK_EQ(out->to_version, head); + } + CHECK_EQ(db->user_version(), head); + } +} + +TEST_MAIN() From e585113daf17a9555ab0a1fb3f2704c6b0f1a59f Mon Sep 17 00:00:00 2001 From: sami Date: Thu, 10 Sep 2026 15:31:00 +0400 Subject: [PATCH 4/7] =?UTF-8?q?daemon:=20adopt=20HandlerResult=20?= =?UTF-8?q?=E2=80=94=20real=20error=20codes=20from=20handlers=20(contracts?= =?UTF-8?q?/=201.4.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebased onto main at 1.4.0. The regenerated Dispatcher returns HandlerResult = expected (ADR 0014); the covariant-return break on all 39 overrides is the swap predicted in daemon/docs/proto-requests-m1.md P1. - dispatcher.hpp/.cpp: Result -> HandlerResult on every override; not_implemented() now returns HandlerError{InternalError, ...} rather than a ParseError forwarded as -32603. - download.get: returns -32010 TaskNotFound with data.taskId. Not a placeholder — with no store, every id is genuinely not-found, which is the real answer for contracts/ fixture download.get.not-found. It becomes a store lookup when store/ is wired in. - uds_roundtrip: the -32603-collapse guard is now a -32010 + data.taskId assertion, the regression guard the P1 note promised. download.add (-32011) and download.probe (-32013) stay InternalError until they have real bodies (canonicalization / probe); they get their fixture codes when that logic lands. All 24 tests green; uds_roundtrip TSan-clean. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Upd9WhG9oppieig5nRDLig --- daemon/src/rpc/dispatcher.cpp | 97 +++++++++++++++-------------- daemon/src/rpc/dispatcher.hpp | 78 +++++++++++------------ daemon/tests/uds_roundtrip_test.cpp | 12 ++-- 3 files changed, 97 insertions(+), 90 deletions(-) diff --git a/daemon/src/rpc/dispatcher.cpp b/daemon/src/rpc/dispatcher.cpp index 4063387..39b76f4 100644 --- a/daemon/src/rpc/dispatcher.cpp +++ b/daemon/src/rpc/dispatcher.cpp @@ -6,12 +6,13 @@ namespace proto = velox::proto; namespace { -// The generated Result carries only proto::ParseError, whose {path, message} the -// generated dispatch() forwards as -32603 data. Until P1 (daemon/docs/proto-requests-m1.md) -// gives handlers a real error channel, an unimplemented method says so plainly here. +// A method whose body arrives with the store / scheduler. Answers -32603 with a clear +// message through the generated HandlerError channel (contracts/ 1.4.0, ADR 0014). template -proto::Result not_implemented(const char* method) { - return std::unexpected(proto::ParseError{method, "not implemented in this build"}); +proto::HandlerResult not_implemented(const char* method) { + return std::unexpected(proto::HandlerError{ + proto::ErrorCode::InternalError, + std::string("not implemented in this build: ") + method}); } } // namespace @@ -20,24 +21,24 @@ proto::Result not_implemented(const char* method) { // Kept as explicit stubs so a direct dispatch() caller (a test, a future in-process client) // gets a clear answer rather than undefined behaviour from a missing override. -proto::Result +proto::HandlerResult VeloxDispatcher::on_session_hello(const proto::SessionHelloParams&) { return not_implemented("session.hello"); } -proto::Result +proto::HandlerResult VeloxDispatcher::on_session_pair(const proto::SessionPairParams&) { return not_implemented("session.pair"); } -proto::Result +proto::HandlerResult VeloxDispatcher::on_session_subscribe(const proto::SessionSubscribeParams&) { return not_implemented("session.subscribe"); } // --- download.list : an empty table, so a client can connect and render --------------- -proto::Result +proto::HandlerResult VeloxDispatcher::on_download_list(const proto::DownloadListParams&) { proto::DownloadListResult r; r.total = 0; @@ -46,141 +47,145 @@ VeloxDispatcher::on_download_list(const proto::DownloadListParams&) { // --- everything else : not implemented until the store and scheduler land ------------- -proto::Result +proto::HandlerResult VeloxDispatcher::on_capture_getRules(const proto::CaptureGetRulesParams&) { return not_implemented("capture.getRules"); } -proto::Result +proto::HandlerResult VeloxDispatcher::on_capture_offer(const proto::CaptureOfferParams&) { return not_implemented("capture.offer"); } -proto::Result +proto::HandlerResult VeloxDispatcher::on_category_list(const proto::CategoryListParams&) { return not_implemented("category.list"); } -proto::Result +proto::HandlerResult VeloxDispatcher::on_category_remove(const proto::CategoryRemoveParams&) { return not_implemented("category.remove"); } -proto::Result +proto::HandlerResult VeloxDispatcher::on_category_upsert(const proto::CategoryUpsertParams&) { return not_implemented("category.upsert"); } -proto::Result +proto::HandlerResult VeloxDispatcher::on_download_add(const proto::DownloadSpec&) { return not_implemented("download.add"); } -proto::Result +proto::HandlerResult VeloxDispatcher::on_download_addBatch(const proto::DownloadAddBatchParams&) { return not_implemented("download.addBatch"); } -proto::Result +proto::HandlerResult VeloxDispatcher::on_download_cancel(const proto::DownloadCancelParams&) { return not_implemented("download.cancel"); } -proto::Result -VeloxDispatcher::on_download_get(const proto::DownloadGetParams&) { - return not_implemented("download.get"); +proto::HandlerResult +VeloxDispatcher::on_download_get(const proto::DownloadGetParams& params) { + // No store is wired yet, so no task exists and every id is genuinely not-found. This + // is the real -32010 answer (contracts/ error fixture download.get.not-found), not a + // placeholder; it becomes a store lookup when store/ is wired in. + return std::unexpected(proto::HandlerError{proto::ErrorCode::TaskNotFound, "no such task", + nlohmann::json{{"taskId", params.taskId}}}); } -proto::Result +proto::HandlerResult VeloxDispatcher::on_download_pause(const proto::DownloadPauseParams&) { return not_implemented("download.pause"); } -proto::Result +proto::HandlerResult VeloxDispatcher::on_download_probe(const proto::DownloadProbeParams&) { return not_implemented("download.probe"); } -proto::Result +proto::HandlerResult VeloxDispatcher::on_download_provideAuth(const proto::DownloadProvideAuthParams&) { return not_implemented("download.provideAuth"); } -proto::Result +proto::HandlerResult VeloxDispatcher::on_download_refreshUrl(const proto::DownloadRefreshUrlParams&) { return not_implemented("download.refreshUrl"); } -proto::Result +proto::HandlerResult VeloxDispatcher::on_download_remove(const proto::DownloadRemoveParams&) { return not_implemented("download.remove"); } -proto::Result +proto::HandlerResult VeloxDispatcher::on_download_resume(const proto::DownloadResumeParams&) { return not_implemented("download.resume"); } -proto::Result +proto::HandlerResult VeloxDispatcher::on_download_start(const proto::DownloadStartParams&) { return not_implemented("download.start"); } -proto::Result +proto::HandlerResult VeloxDispatcher::on_download_update(const proto::DownloadUpdateParams&) { return not_implemented("download.update"); } -proto::Result +proto::HandlerResult VeloxDispatcher::on_grabber_harvest(const proto::GrabberHarvestParams&) { return not_implemented("grabber.harvest"); } -proto::Result +proto::HandlerResult VeloxDispatcher::on_grabber_start(const proto::GrabberStartParams&) { return not_implemented("grabber.start"); } -proto::Result +proto::HandlerResult VeloxDispatcher::on_grabber_status(const proto::GrabberStatusParams&) { return not_implemented("grabber.status"); } -proto::Result VeloxDispatcher::on_limiter_get(const proto::LimiterGetParams&) { +proto::HandlerResult VeloxDispatcher::on_limiter_get(const proto::LimiterGetParams&) { return not_implemented("limiter.get"); } -proto::Result VeloxDispatcher::on_limiter_set(const proto::Limiter&) { +proto::HandlerResult VeloxDispatcher::on_limiter_set(const proto::Limiter&) { return not_implemented("limiter.set"); } -proto::Result +proto::HandlerResult VeloxDispatcher::on_media_addVariant(const proto::MediaAddVariantParams&) { return not_implemented("media.addVariant"); } -proto::Result +proto::HandlerResult VeloxDispatcher::on_media_listVariants(const proto::MediaListVariantsParams&) { return not_implemented("media.listVariants"); } -proto::Result +proto::HandlerResult VeloxDispatcher::on_queue_list(const proto::QueueListParams&) { return not_implemented("queue.list"); } -proto::Result +proto::HandlerResult VeloxDispatcher::on_queue_reorder(const proto::QueueReorderParams&) { return not_implemented("queue.reorder"); } -proto::Result +proto::HandlerResult VeloxDispatcher::on_queue_start(const proto::QueueStartParams&) { return not_implemented("queue.start"); } -proto::Result +proto::HandlerResult VeloxDispatcher::on_queue_stop(const proto::QueueStopParams&) { return not_implemented("queue.stop"); } -proto::Result +proto::HandlerResult VeloxDispatcher::on_queue_upsert(const proto::QueueUpsertParams&) { return not_implemented("queue.upsert"); } -proto::Result +proto::HandlerResult VeloxDispatcher::on_rules_list(const proto::RulesListParams&) { return not_implemented("rules.list"); } -proto::Result +proto::HandlerResult VeloxDispatcher::on_rules_upsert(const proto::RulesUpsertParams&) { return not_implemented("rules.upsert"); } -proto::Result +proto::HandlerResult VeloxDispatcher::on_schedule_get(const proto::ScheduleGetParams&) { return not_implemented("schedule.get"); } -proto::Result +proto::HandlerResult VeloxDispatcher::on_schedule_set(const proto::ScheduleSetParams&) { return not_implemented("schedule.set"); } -proto::Result +proto::HandlerResult VeloxDispatcher::on_settings_get(const proto::SettingsGetParams&) { return not_implemented("settings.get"); } -proto::Result +proto::HandlerResult VeloxDispatcher::on_settings_set(const proto::SettingsSetParams&) { return not_implemented("settings.set"); } diff --git a/daemon/src/rpc/dispatcher.hpp b/daemon/src/rpc/dispatcher.hpp index 5e7e97a..1e21e70 100644 --- a/daemon/src/rpc/dispatcher.hpp +++ b/daemon/src/rpc/dispatcher.hpp @@ -21,82 +21,82 @@ namespace velox::daemon::rpc { class VeloxDispatcher final : public velox::proto::Dispatcher { public: - velox::proto::Result + velox::proto::HandlerResult on_capture_getRules(const velox::proto::CaptureGetRulesParams&) override; - velox::proto::Result + velox::proto::HandlerResult on_capture_offer(const velox::proto::CaptureOfferParams&) override; - velox::proto::Result + velox::proto::HandlerResult on_category_list(const velox::proto::CategoryListParams&) override; - velox::proto::Result + velox::proto::HandlerResult on_category_remove(const velox::proto::CategoryRemoveParams&) override; - velox::proto::Result + velox::proto::HandlerResult on_category_upsert(const velox::proto::CategoryUpsertParams&) override; - velox::proto::Result + velox::proto::HandlerResult on_download_add(const velox::proto::DownloadSpec&) override; - velox::proto::Result + velox::proto::HandlerResult on_download_addBatch(const velox::proto::DownloadAddBatchParams&) override; - velox::proto::Result + velox::proto::HandlerResult on_download_cancel(const velox::proto::DownloadCancelParams&) override; - velox::proto::Result + velox::proto::HandlerResult on_download_get(const velox::proto::DownloadGetParams&) override; - velox::proto::Result + velox::proto::HandlerResult on_download_list(const velox::proto::DownloadListParams&) override; - velox::proto::Result + velox::proto::HandlerResult on_download_pause(const velox::proto::DownloadPauseParams&) override; - velox::proto::Result + velox::proto::HandlerResult on_download_probe(const velox::proto::DownloadProbeParams&) override; - velox::proto::Result + velox::proto::HandlerResult on_download_provideAuth(const velox::proto::DownloadProvideAuthParams&) override; - velox::proto::Result + velox::proto::HandlerResult on_download_refreshUrl(const velox::proto::DownloadRefreshUrlParams&) override; - velox::proto::Result + velox::proto::HandlerResult on_download_remove(const velox::proto::DownloadRemoveParams&) override; - velox::proto::Result + velox::proto::HandlerResult on_download_resume(const velox::proto::DownloadResumeParams&) override; - velox::proto::Result + velox::proto::HandlerResult on_download_start(const velox::proto::DownloadStartParams&) override; - velox::proto::Result + velox::proto::HandlerResult on_download_update(const velox::proto::DownloadUpdateParams&) override; - velox::proto::Result + velox::proto::HandlerResult on_grabber_harvest(const velox::proto::GrabberHarvestParams&) override; - velox::proto::Result + velox::proto::HandlerResult on_grabber_start(const velox::proto::GrabberStartParams&) override; - velox::proto::Result + velox::proto::HandlerResult on_grabber_status(const velox::proto::GrabberStatusParams&) override; - velox::proto::Result + velox::proto::HandlerResult on_limiter_get(const velox::proto::LimiterGetParams&) override; - velox::proto::Result on_limiter_set(const velox::proto::Limiter&) override; - velox::proto::Result + velox::proto::HandlerResult on_limiter_set(const velox::proto::Limiter&) override; + velox::proto::HandlerResult on_media_addVariant(const velox::proto::MediaAddVariantParams&) override; - velox::proto::Result + velox::proto::HandlerResult on_media_listVariants(const velox::proto::MediaListVariantsParams&) override; - velox::proto::Result + velox::proto::HandlerResult on_queue_list(const velox::proto::QueueListParams&) override; - velox::proto::Result + velox::proto::HandlerResult on_queue_reorder(const velox::proto::QueueReorderParams&) override; - velox::proto::Result + velox::proto::HandlerResult on_queue_start(const velox::proto::QueueStartParams&) override; - velox::proto::Result + velox::proto::HandlerResult on_queue_stop(const velox::proto::QueueStopParams&) override; - velox::proto::Result + velox::proto::HandlerResult on_queue_upsert(const velox::proto::QueueUpsertParams&) override; - velox::proto::Result + velox::proto::HandlerResult on_rules_list(const velox::proto::RulesListParams&) override; - velox::proto::Result + velox::proto::HandlerResult on_rules_upsert(const velox::proto::RulesUpsertParams&) override; - velox::proto::Result + velox::proto::HandlerResult on_schedule_get(const velox::proto::ScheduleGetParams&) override; - velox::proto::Result + velox::proto::HandlerResult on_schedule_set(const velox::proto::ScheduleSetParams&) override; - velox::proto::Result + velox::proto::HandlerResult on_session_hello(const velox::proto::SessionHelloParams&) override; - velox::proto::Result + velox::proto::HandlerResult on_session_pair(const velox::proto::SessionPairParams&) override; - velox::proto::Result + velox::proto::HandlerResult on_session_subscribe(const velox::proto::SessionSubscribeParams&) override; - velox::proto::Result + velox::proto::HandlerResult on_settings_get(const velox::proto::SettingsGetParams&) override; - velox::proto::Result + velox::proto::HandlerResult on_settings_set(const velox::proto::SettingsSetParams&) override; }; diff --git a/daemon/tests/uds_roundtrip_test.cpp b/daemon/tests/uds_roundtrip_test.cpp index 39f8ea9..a221f9a 100644 --- a/daemon/tests/uds_roundtrip_test.cpp +++ b/daemon/tests/uds_roundtrip_test.cpp @@ -148,17 +148,19 @@ void run() { ::close(c); } - // --- download.get -> -32603 for now: documents the P1 codegen gap --------------- - // (proto-requests-m1.md P1: handlers cannot yet return -32010. When P1 lands this - // check flips to -32010 and is the regression guard for it.) + // --- download.get on an unknown id -> -32010, with data.taskId ------------------ + // (contracts/ error fixture download.get.not-found; reachable now that 1.4.0 gave + // handlers the HandlerError channel — ADR 0014.) { const int c = connect_client(sock); + const std::string missing = "00000000-0000-4000-8000-000000000000"; const json reply = call(c, {{"jsonrpc", "2.0"}, {"id", 6}, {"method", "download.get"}, - {"params", {{"taskId", "00000000-0000-4000-8000-000000000000"}}}}); + {"params", {{"taskId", missing}}}}); CHECK(reply.contains("error")); - CHECK_EQ(reply["error"]["code"].get(), -32603); + CHECK_EQ(reply["error"]["code"].get(), -32010); + CHECK_EQ(reply["error"]["data"]["taskId"].get(), missing); ::close(c); } From dab071c41a795275f24a02257b5ce91ce17f2b5f Mon Sep 17 00:00:00 2001 From: sami Date: Thu, 10 Sep 2026 15:44:28 +0400 Subject: [PATCH 5/7] =?UTF-8?q?daemon:=20rpc/ws=5Fserver=20=E2=80=94=20loo?= =?UTF-8?q?pback=20WebSocket=20transport=20+=20pairing=20(build=20step=201?= =?UTF-8?q?,=20second=20half)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The extension's fallback transport (docs/05 §4). veloxd now also listens on 127.0.0.1, first free port in 52000-52016, and writes it to /ws.port (0600). - rpc/ws_frame — RFC 6455 frame codec. Incremental; reassembles continuation frames; enforces "client frames MUST be masked" (§5.1); caps a reassembled message at 8 MiB. This is the attacker-adjacent parser, so it has its own test table. - rpc/ws_handshake — HTTP upgrade parse, Sec-WebSocket-Accept (SHA-1 + base64 via libcrypto), and the two non-negotiable checks: an Origin header must be present and must be moz-extension:// (a page cannot pair). Version must be 13. - rpc/ws_server — per-connection Handshake -> Open state machine on the shared EventLoop. Token gate: session.pair mints a token behind the approver + rate limiter; session.hello must present a valid one; every other method is -32002 until authed. Privileged methods are refused -32003 by the generated dispatch(). Ping -> Pong; Close echoed. session.hello major-version mismatch -> -32001. - rpc/pairing — PairingApprover interface + EnvAutoApprover dev stub (approves iff VELOX_PAIR_AUTO=1); PairingRateLimiter (5 failures / 60 s per origin, then 60 s lockout -> -32014, survives reconnect); a four-digit code generator. - store/pairings — the pairings table: create() returns the plaintext token once and stores only its SHA-256; find_active_by_token, touch, revoke, list_active. - util/crypto — sha1 / sha256_hex / base64 / random_token over libcrypto. - store/sqlite — pin the DB file (and -wal/-shm) to 0600. - runtime_dir — resolve_data_dir() for $XDG_DATA_HOME/velox (velox.db). - main.cpp — opens + migrates velox.db, starts both transports; a WS bind failure is logged, not fatal (capture must fail open, the Unix socket still serves the GUI/CLI). Real gap, flagged not hidden: the pairing prompt is EnvAutoApprover for now — a GUI dialog / desktop notification is build step 7. Pairing needs VELOX_PAIR_AUTO=1 until then. Tests (ASan+UBSan and TSan clean): veloxd.ws_frame (codec + handshake vectors incl. the RFC 6455 §1.3 accept sample), veloxd.pairings (token create/find/revoke, hash-not-token, rate-limit window + lockout + per-origin isolation + success reset), veloxd.ws_server (full flow: 101 handshake, -32002 gate, deny-then-approve pairing, hello-with-token, -32003 privileged refusal, real download.list). 27 daemon/cli tests green; full tree green. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Upd9WhG9oppieig5nRDLig --- daemon/CMakeLists.txt | 29 ++- daemon/src/main.cpp | 44 +++- daemon/src/rpc/pairing.cpp | 54 ++++ daemon/src/rpc/pairing.hpp | 71 ++++++ daemon/src/rpc/runtime_dir.cpp | 20 ++ daemon/src/rpc/runtime_dir.hpp | 5 + daemon/src/rpc/ws_frame.cpp | 152 +++++++++++ daemon/src/rpc/ws_frame.hpp | 61 +++++ daemon/src/rpc/ws_handshake.cpp | 121 +++++++++ daemon/src/rpc/ws_handshake.hpp | 30 +++ daemon/src/rpc/ws_server.cpp | 440 ++++++++++++++++++++++++++++++++ daemon/src/rpc/ws_server.hpp | 92 +++++++ daemon/src/store/pairings.cpp | 104 ++++++++ daemon/src/store/pairings.hpp | 53 ++++ daemon/src/store/sqlite.cpp | 9 + daemon/src/util/crypto.cpp | 57 +++++ daemon/src/util/crypto.hpp | 29 +++ daemon/tests/CMakeLists.txt | 31 ++- daemon/tests/pairings_test.cpp | 87 +++++++ daemon/tests/ws_frame_test.cpp | 150 +++++++++++ daemon/tests/ws_server_test.cpp | 216 ++++++++++++++++ 21 files changed, 1823 insertions(+), 32 deletions(-) create mode 100644 daemon/src/rpc/pairing.cpp create mode 100644 daemon/src/rpc/pairing.hpp create mode 100644 daemon/src/rpc/ws_frame.cpp create mode 100644 daemon/src/rpc/ws_frame.hpp create mode 100644 daemon/src/rpc/ws_handshake.cpp create mode 100644 daemon/src/rpc/ws_handshake.hpp create mode 100644 daemon/src/rpc/ws_server.cpp create mode 100644 daemon/src/rpc/ws_server.hpp create mode 100644 daemon/src/store/pairings.cpp create mode 100644 daemon/src/store/pairings.hpp create mode 100644 daemon/src/util/crypto.cpp create mode 100644 daemon/src/util/crypto.hpp create mode 100644 daemon/tests/pairings_test.cpp create mode 100644 daemon/tests/ws_frame_test.cpp create mode 100644 daemon/tests/ws_server_test.cpp diff --git a/daemon/CMakeLists.txt b/daemon/CMakeLists.txt index d0e442b..cae6335 100644 --- a/daemon/CMakeLists.txt +++ b/daemon/CMakeLists.txt @@ -1,17 +1,18 @@ -# daemon/ produces the veloxd binary and the veloxd_rpc static library it is built from. +# daemon/ produces the veloxd binary and the static libraries it is built from. # Owned by lane DAEMON. Wired in by PKG via add_subdirectory(daemon) in the root file, # guarded on this file existing. # # Layering (CLAUDE.md §3): depends on velox::core and velox::proto. No Qt. The engine -# (velox::core) is not linked yet — it arrives when sched/ and the task glue land; this -# first drop is the RPC transport + dispatcher skeleton so the CLI and GUI have a real -# server to talk to. +# (velox::core) is not linked yet — it arrives when sched/ and the task glue land. This +# drop is the RPC transports (Unix socket + loopback WebSocket), the SQLite store, and a +# dispatcher skeleton so the CLI and GUI have a real server to talk to. if(NOT TARGET nlohmann_json::nlohmann_json) find_package(nlohmann_json 3.11 REQUIRED) endif() find_package(Threads REQUIRED) find_package(SQLite3 REQUIRED) +find_package(OpenSSL REQUIRED) # libcrypto: WebSocket accept hash, pairing token hash # --- generated: migrations_embedded.hpp from src/store/migrations/*.sql --------------- set(_mig_dir ${CMAKE_CURRENT_SOURCE_DIR}/src/store/migrations) @@ -26,10 +27,12 @@ add_custom_command( VERBATIM) add_custom_target(veloxd_migrations_hdr DEPENDS ${_mig_hdr}) -# --- veloxd_store — SQLite store + migrations ----------------------------------------- +# --- veloxd_store — SQLite store, migrations, crypto helpers -------------------------- add_library(veloxd_store STATIC + src/util/crypto.cpp src/store/sqlite.cpp src/store/migrations.cpp + src/store/pairings.cpp ${_mig_hdr} ) add_library(velox::daemon_store ALIAS veloxd_store) @@ -39,31 +42,33 @@ target_include_directories(veloxd_store ) target_compile_features(veloxd_store PUBLIC cxx_std_23) target_compile_options(veloxd_store PRIVATE -Wall -Wextra -Wpedantic -Werror) -target_link_libraries(veloxd_store PUBLIC SQLite::SQLite3) +target_link_libraries(veloxd_store PUBLIC SQLite::SQLite3 PRIVATE OpenSSL::Crypto) -# --- veloxd_rpc — the server library ---------------------------------------------------- +# --- veloxd_rpc — the RPC transports + dispatcher ------------------------------------ add_library(veloxd_rpc STATIC src/rpc/runtime_dir.cpp src/rpc/event_loop.cpp src/rpc/uds_server.cpp + src/rpc/ws_frame.cpp + src/rpc/ws_handshake.cpp + src/rpc/ws_server.cpp + src/rpc/pairing.cpp src/rpc/dispatcher.cpp ) add_library(velox::daemon_rpc ALIAS veloxd_rpc) -target_include_directories(veloxd_rpc - PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/src -) +target_include_directories(veloxd_rpc PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/src) target_compile_features(veloxd_rpc PUBLIC cxx_std_23) target_compile_options(veloxd_rpc PRIVATE -Wall -Wextra -Wpedantic -Werror) target_link_libraries(veloxd_rpc - PUBLIC velox::proto nlohmann_json::nlohmann_json Threads::Threads + PUBLIC velox::proto veloxd_store nlohmann_json::nlohmann_json Threads::Threads ) # --- veloxd — the daemon binary ------------------------------------------------------- add_executable(veloxd src/main.cpp) target_compile_features(veloxd PRIVATE cxx_std_23) target_compile_options(veloxd PRIVATE -Wall -Wextra -Wpedantic -Werror) -target_link_libraries(veloxd PRIVATE veloxd_rpc veloxd_store) +target_link_libraries(veloxd PRIVATE veloxd_rpc) if(VELOX_BUILD_TESTS AND EXISTS ${CMAKE_CURRENT_SOURCE_DIR}/tests/CMakeLists.txt) add_subdirectory(tests) diff --git a/daemon/src/main.cpp b/daemon/src/main.cpp index e410538..f8e5d11 100644 --- a/daemon/src/main.cpp +++ b/daemon/src/main.cpp @@ -1,8 +1,9 @@ // veloxd — the Velox download-manager daemon. // -// This drop wires up the Unix-socket RPC transport and a dispatcher skeleton so the CLI -// and GUI have a real server to speak to (AGENT-DAEMON.md build order, step 1). The -// WebSocket transport, the SQLite store and the scheduler land next. +// Wires up both RPC transports (Unix socket + loopback WebSocket), the SQLite store, +// and a dispatcher skeleton so the CLI and GUI have a real server to speak to +// (AGENT-DAEMON.md build order, steps 1 and 3). The scheduler and the engine link land +// next. #include #include @@ -16,8 +17,12 @@ #include "rpc/dispatcher.hpp" #include "rpc/event_loop.hpp" +#include "rpc/pairing.hpp" #include "rpc/runtime_dir.hpp" #include "rpc/uds_server.hpp" +#include "rpc/ws_server.hpp" +#include "store/migrations.hpp" +#include "store/sqlite.hpp" #include "version.hpp" namespace { @@ -80,15 +85,46 @@ int main() { ::sigaction(SIGTERM, &sa, nullptr); ::signal(SIGPIPE, SIG_IGN); // a client vanishing mid-write is EPIPE, never a signal + std::string data_dir; + if (const auto ec = velox::daemon::rpc::resolve_data_dir(data_dir)) { + std::cerr << "veloxd: cannot prepare data directory: " << ec.message() << "\n"; + return 1; + } + auto db = velox::daemon::store::Db::open(data_dir + "/velox.db"); + if (!db) { + std::cerr << "veloxd: cannot open " << data_dir << "/velox.db: " + << db.error().to_string() << "\n"; + return 1; + } + if (const auto m = velox::daemon::store::migrate_to_head(*db); !m) { + std::cerr << "veloxd: schema migration failed: " << m.error().to_string() << "\n"; + return 1; + } + velox::daemon::rpc::VeloxDispatcher dispatcher; + velox::daemon::rpc::UdsServer uds(loop, dispatcher, rt.socket_path()); if (const auto ec = uds.start()) { std::cerr << "veloxd: cannot listen on " << rt.socket_path() << ": " << ec.message() << "\n"; return 1; } - std::cout << "veloxd: listening on " << uds.socket_path() << "\n"; + + // The WebSocket transport is the extension's fallback (docs/05 §4); the Unix socket is + // the primary. If every port in 52000-52016 is taken, log it and carry on rather than + // refusing to start — capture must fail open, and the GUI/CLI still have the socket. + // TODO(build step 7): replace EnvAutoApprover with a GUI-dialog / desktop-notification + // approver. Until then pairing needs VELOX_PAIR_AUTO=1. + velox::daemon::rpc::EnvAutoApprover approver; + velox::daemon::rpc::WsServer ws(loop, dispatcher, *db, approver, rt); + if (const auto ec = ws.start()) { + std::cerr << "veloxd: WebSocket transport unavailable (" << ec.message() + << "); the extension fallback will not work this run\n"; + } else { + std::cout << "veloxd: WebSocket transport on 127.0.0.1:" << ws.port() << "\n"; + } + loop.run(); std::cout << "veloxd: shutting down\n"; diff --git a/daemon/src/rpc/pairing.cpp b/daemon/src/rpc/pairing.cpp new file mode 100644 index 0000000..6390d0c --- /dev/null +++ b/daemon/src/rpc/pairing.cpp @@ -0,0 +1,54 @@ +#include "rpc/pairing.hpp" + +#include +#include +#include + +namespace velox::daemon::rpc { + +bool EnvAutoApprover::approve(const PairingRequest& req) { + (void)req; + const char* v = std::getenv("VELOX_PAIR_AUTO"); + return v != nullptr && std::string_view(v) == "1"; +} + +PairingRateLimiter::Decision PairingRateLimiter::check(std::string_view origin, + Clock::time_point now) { + auto it = by_origin_.find(origin); + if (it == by_origin_.end()) return {true, 0}; + + Entry& e = it->second; + if (now < e.locked_until) { + const auto left = + std::chrono::duration_cast(e.locked_until - now).count(); + return {false, static_cast(left) + 1}; + } + + while (!e.failures.empty() && now - e.failures.front() > kWindow) e.failures.pop_front(); + if (static_cast(e.failures.size()) >= kMaxPerWindow) { + e.locked_until = now + kLockout; + return {false, static_cast(kLockout.count())}; + } + return {true, 0}; +} + +void PairingRateLimiter::record_failure(std::string_view origin, Clock::time_point now) { + Entry& e = by_origin_.try_emplace(std::string(origin)).first->second; + while (!e.failures.empty() && now - e.failures.front() > kWindow) e.failures.pop_front(); + e.failures.push_back(now); + if (static_cast(e.failures.size()) >= kMaxPerWindow) e.locked_until = now + kLockout; +} + +void PairingRateLimiter::record_success(std::string_view origin) { + by_origin_.erase(std::string(origin)); +} + +std::string make_pairing_code() { + std::random_device rd; + std::uniform_int_distribution d(0, 9999); + char buf[5]; + std::snprintf(buf, sizeof(buf), "%04d", d(rd)); + return std::string(buf); +} + +} // namespace velox::daemon::rpc diff --git a/daemon/src/rpc/pairing.hpp b/daemon/src/rpc/pairing.hpp new file mode 100644 index 0000000..bdcaab6 --- /dev/null +++ b/daemon/src/rpc/pairing.hpp @@ -0,0 +1,71 @@ +#pragma once + +// The human side of session.pair: showing the user a code and getting an Allow / Deny. +// +// docs/05 §4 wants a GUI dialog when the GUI is connected, else a desktop notification +// with actions. Neither exists yet (that is integration, build step 7), so this is an +// interface with a development stub. The token mechanism around it — generation, hashing, +// storage, revocation, rate limiting — is real. + +#include +#include +#include +#include +#include +#include + +namespace velox::daemon::rpc { + +struct PairingRequest { + std::string origin; // moz-extension://, from the verified Origin header + std::string client_name; // SessionPairParams.clientName, shown in the prompt + std::string code; // four digits, shown to the user and echoable in Options +}; + +class PairingApprover { +public: + virtual ~PairingApprover() = default; + // Returns true iff the user approved. Must not block the RPC loop indefinitely; the + // real notification-backed approver will run async and is not this shape. + virtual bool approve(const PairingRequest& req) = 0; +}; + +// Development / test stub: approves iff $VELOX_PAIR_AUTO == "1", otherwise denies. Never +// shipped as the default in a release build. +class EnvAutoApprover final : public PairingApprover { +public: + bool approve(const PairingRequest& req) override; +}; + +// Per-origin failed-attempt limiter: 5 failures in a rolling 60 s, then a 60 s lockout +// (docs/05 §4, fixture session.pair.rate-limited). In-memory and keyed by origin, so a +// reconnect does not reset it. A success clears the origin's history. +class PairingRateLimiter { +public: + using Clock = std::chrono::steady_clock; + + struct Decision { + bool allowed; + int retry_after_sec; // set when !allowed + }; + + Decision check(std::string_view origin, Clock::time_point now = Clock::now()); + void record_failure(std::string_view origin, Clock::time_point now = Clock::now()); + void record_success(std::string_view origin); + +private: + static constexpr int kMaxPerWindow = 5; + static constexpr auto kWindow = std::chrono::seconds(60); + static constexpr auto kLockout = std::chrono::seconds(60); + + struct Entry { + std::deque failures; + Clock::time_point locked_until{}; + }; + std::map> by_origin_; +}; + +// A four-digit code for the prompt. Uniform over 0000-9999. +std::string make_pairing_code(); + +} // namespace velox::daemon::rpc diff --git a/daemon/src/rpc/runtime_dir.cpp b/daemon/src/rpc/runtime_dir.cpp index e387916..d293411 100644 --- a/daemon/src/rpc/runtime_dir.cpp +++ b/daemon/src/rpc/runtime_dir.cpp @@ -53,4 +53,24 @@ std::error_code resolve_runtime_dir(RuntimeDir& out) { return {}; } +std::error_code resolve_data_dir(std::string& out) { + std::string base; + if (const char* xdg = ::getenv("XDG_DATA_HOME"); xdg != nullptr && xdg[0] != '\0') { + base = xdg; + } else if (const char* home = ::getenv("HOME"); home != nullptr && home[0] != '\0') { + base = std::string(home) + "/.local/share"; + } else { + return errc(ENOENT); + } + if (!base.empty() && base.back() == '/') base.pop_back(); + + // Create the XDG base components leniently, then the velox dir with a strict check. + ::mkdir(base.c_str(), 0700); + const std::string dir = base + "/velox"; + if (auto ec = ensure_private_dir(dir)) return ec; + + out = dir; + return {}; +} + } // namespace velox::daemon::rpc diff --git a/daemon/src/rpc/runtime_dir.hpp b/daemon/src/rpc/runtime_dir.hpp index b71a529..3b4f5ab 100644 --- a/daemon/src/rpc/runtime_dir.hpp +++ b/daemon/src/rpc/runtime_dir.hpp @@ -27,4 +27,9 @@ struct RuntimeDir { // owner, wrong perms, mkdir failed). std::error_code resolve_runtime_dir(RuntimeDir& out); +// The persistent data directory: $XDG_DATA_HOME/velox or ~/.local/share/velox +// (docs/01 §5). Created 0700 if absent. Holds velox.db. On success `out` is the absolute +// path with no trailing slash. +std::error_code resolve_data_dir(std::string& out); + } // namespace velox::daemon::rpc diff --git a/daemon/src/rpc/ws_frame.cpp b/daemon/src/rpc/ws_frame.cpp new file mode 100644 index 0000000..69a1b09 --- /dev/null +++ b/daemon/src/rpc/ws_frame.cpp @@ -0,0 +1,152 @@ +#include "rpc/ws_frame.hpp" + +#include + +namespace velox::daemon::rpc { + +namespace { + +bool is_control(WsOpcode op) { + return op == WsOpcode::Close || op == WsOpcode::Ping || op == WsOpcode::Pong; +} +bool is_known_data(WsOpcode op) { + return op == WsOpcode::Text || op == WsOpcode::Binary; +} + +} // namespace + +WsFrameReader::Status WsFrameReader::feed(std::string_view bytes, + std::vector& messages) { + buf_.append(bytes); + + for (;;) { + if (buf_.size() < 2) return Status::Ok; + + const auto b0 = static_cast(buf_[0]); + const auto b1 = static_cast(buf_[1]); + + const bool fin = (b0 & 0x80) != 0; + const std::uint8_t rsv = b0 & 0x70; + const auto opcode = static_cast(b0 & 0x0F); + const bool masked = (b1 & 0x80) != 0; + std::uint64_t len = b1 & 0x7F; + + if (rsv != 0) { + error_ = "RSV bits set with no negotiated extension"; + return Status::ProtocolError; + } + if (!masked) { + error_ = "client frame is not masked"; // RFC 6455 §5.1 + return Status::ProtocolError; + } + + std::size_t header = 2; + if (len == 126) { + if (buf_.size() < 4) return Status::Ok; + len = (static_cast(static_cast(buf_[2])) << 8) | + static_cast(buf_[3]); + header = 4; + } else if (len == 127) { + if (buf_.size() < 10) return Status::Ok; + len = 0; + for (int i = 0; i < 8; ++i) + len = (len << 8) | static_cast(buf_[2 + i]); + header = 10; + } + + if (is_control(opcode)) { + if (!fin) { + error_ = "fragmented control frame"; + return Status::ProtocolError; + } + if (len > 125) { + error_ = "control frame payload over 125 bytes"; + return Status::ProtocolError; + } + } + if (len > kMaxMessageBytes || frag_.size() + len > kMaxMessageBytes) { + error_ = "message exceeds the size cap"; + return Status::MessageTooBig; + } + + const std::size_t need = header + 4 + static_cast(len); + if (buf_.size() < need) return Status::Ok; + + const char* mask = buf_.data() + header; + const char* body = mask + 4; + + std::string payload; + payload.resize(static_cast(len)); + for (std::uint64_t i = 0; i < len; ++i) + payload[i] = static_cast(body[i] ^ mask[i & 3]); + + buf_.erase(0, need); + + // --- dispatch by opcode --------------------------------------------------- + if (is_control(opcode)) { + messages.push_back(WsMessage{opcode, std::move(payload)}); + continue; + } + + if (opcode == WsOpcode::Continuation) { + if (!in_fragment_) { + error_ = "continuation frame with nothing to continue"; + return Status::ProtocolError; + } + frag_.insert(frag_.end(), payload.begin(), payload.end()); + if (fin) { + messages.push_back( + WsMessage{frag_opcode_, std::string(frag_.begin(), frag_.end())}); + frag_.clear(); + in_fragment_ = false; + } + continue; + } + + if (!is_known_data(opcode)) { + error_ = "unknown opcode"; + return Status::ProtocolError; + } + if (in_fragment_) { + error_ = "new data frame started mid-fragment"; + return Status::ProtocolError; + } + if (fin) { + messages.push_back(WsMessage{opcode, std::move(payload)}); + } else { + frag_opcode_ = opcode; + in_fragment_ = true; + frag_.assign(payload.begin(), payload.end()); + } + } +} + +std::string ws_encode(WsOpcode opcode, std::string_view payload) { + std::string out; + out.push_back(static_cast(0x80 | static_cast(opcode))); // FIN + opcode + + const std::size_t n = payload.size(); + if (n < 126) { + out.push_back(static_cast(n)); + } else if (n <= 0xFFFF) { + out.push_back(static_cast(126)); + out.push_back(static_cast((n >> 8) & 0xFF)); + out.push_back(static_cast(n & 0xFF)); + } else { + out.push_back(static_cast(127)); + for (int i = 7; i >= 0; --i) + out.push_back(static_cast((static_cast(n) >> (i * 8)) & 0xFF)); + } + out.append(payload); // server frames are never masked + return out; +} + +std::string ws_close_payload(std::uint16_t code, std::string_view reason) { + std::string p; + p.push_back(static_cast((code >> 8) & 0xFF)); + p.push_back(static_cast(code & 0xFF)); + p.append(reason); + return p; +} + +} // namespace velox::daemon::rpc diff --git a/daemon/src/rpc/ws_frame.hpp b/daemon/src/rpc/ws_frame.hpp new file mode 100644 index 0000000..abe281a --- /dev/null +++ b/daemon/src/rpc/ws_frame.hpp @@ -0,0 +1,61 @@ +#pragma once + +// RFC 6455 frame codec — the security-sensitive parser on the loopback WebSocket +// transport. Incremental: feed() takes whatever bytes arrived and yields whole messages. +// A client frame MUST be masked (RFC 6455 §5.1); an unmasked client frame is a protocol +// error and the caller must close 1002. +// +// Kept deliberately small: text and binary data frames (reassembled across continuation +// frames), plus ping / pong / close control frames. No extensions, no RSV bits. + +#include +#include +#include +#include + +namespace velox::daemon::rpc { + +enum class WsOpcode : std::uint8_t { + Continuation = 0x0, + Text = 0x1, + Binary = 0x2, + Close = 0x8, + Ping = 0x9, + Pong = 0xA, +}; + +struct WsMessage { + WsOpcode opcode; // Text, Binary, Close, Ping or Pong (never Continuation) + std::string payload; // reassembled; unmasked +}; + +class WsFrameReader { +public: + enum class Status { Ok, ProtocolError, MessageTooBig }; + + // Append `bytes` and pull out every message they complete. On a non-Ok status the + // caller sends a Close and drops the connection; `messages` still holds anything + // decoded before the fault. + Status feed(std::string_view bytes, std::vector& messages); + + std::string_view error() const noexcept { return error_; } + +private: + // Cap on a single reassembled message. A JSON-RPC call over this transport is small; + // past this the peer is misbehaving. + static constexpr std::size_t kMaxMessageBytes = 8 * 1024 * 1024; + + std::string buf_; // undecoded bytes + std::vector frag_; // partial data message across continuations + WsOpcode frag_opcode_ = WsOpcode::Text; + bool in_fragment_ = false; + std::string error_; +}; + +// Build a server->client frame (never masked). `payload` may be empty for Close/Ping/Pong. +std::string ws_encode(WsOpcode opcode, std::string_view payload); + +// A Close frame body: 2-byte big-endian status code, optional UTF-8 reason. +std::string ws_close_payload(std::uint16_t code, std::string_view reason = {}); + +} // namespace velox::daemon::rpc diff --git a/daemon/src/rpc/ws_handshake.cpp b/daemon/src/rpc/ws_handshake.cpp new file mode 100644 index 0000000..a4dc6df --- /dev/null +++ b/daemon/src/rpc/ws_handshake.cpp @@ -0,0 +1,121 @@ +#include "rpc/ws_handshake.hpp" + +#include +#include +#include +#include +#include + +#include "util/crypto.hpp" + +namespace velox::daemon::rpc { + +namespace { + +constexpr std::string_view kGuid = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; + +std::string lower(std::string_view s) { + std::string out(s); + std::transform(out.begin(), out.end(), out.begin(), + [](unsigned char c) { return static_cast(std::tolower(c)); }); + return out; +} + +std::string_view trim(std::string_view s) { + while (!s.empty() && (s.front() == ' ' || s.front() == '\t')) s.remove_prefix(1); + while (!s.empty() && (s.back() == ' ' || s.back() == '\t' || s.back() == '\r')) + s.remove_suffix(1); + return s; +} + +std::string simple_response(std::string_view status_line, std::string_view body) { + std::string r; + r.append("HTTP/1.1 ").append(status_line).append("\r\n"); + r.append("Content-Length: ").append(std::to_string(body.size())).append("\r\n"); + r.append("Connection: close\r\n\r\n"); + r.append(body); + return r; +} + +bool looks_like_extension_origin(std::string_view origin) { + // moz-extension://. We do not pin a specific extension id here — the + // pairing token is the identity; this only rejects a page origin (http/https/file). + return origin.rfind("moz-extension://", 0) == 0 && origin.size() > 16; +} + +} // namespace + +std::string ws_accept_key(std::string_view sec_websocket_key) { + std::string concat(sec_websocket_key); + concat.append(kGuid); + const auto digest = velox::daemon::crypto::sha1(concat); + return velox::daemon::crypto::base64_encode(digest.data(), digest.size()); +} + +HandshakeResult ws_try_handshake(std::string_view buffer) { + HandshakeResult res; + + const auto end = buffer.find("\r\n\r\n"); + if (end == std::string_view::npos) return res; // headers still arriving + res.complete = true; + res.consumed = end + 4; + + const std::string_view head = buffer.substr(0, end); + const auto first_nl = head.find("\r\n"); + const std::string_view request_line = head.substr(0, first_nl); + + std::map headers; + std::size_t pos = (first_nl == std::string_view::npos) ? head.size() : first_nl + 2; + while (pos < head.size()) { + const auto nl = head.find("\r\n", pos); + const std::string_view line = + head.substr(pos, nl == std::string_view::npos ? head.size() - pos : nl - pos); + const auto colon = line.find(':'); + if (colon != std::string_view::npos) { + headers[lower(trim(line.substr(0, colon)))] = + std::string(trim(line.substr(colon + 1))); + } + if (nl == std::string_view::npos) break; + pos = nl + 2; + } + + auto get = [&](const char* k) -> std::string_view { + const auto it = headers.find(k); + return it == headers.end() ? std::string_view{} : std::string_view{it->second}; + }; + + const bool is_get = request_line.rfind("GET ", 0) == 0; + const bool upgrade_ws = lower(get("upgrade")).find("websocket") != std::string::npos; + const bool conn_upgrade = lower(get("connection")).find("upgrade") != std::string::npos; + const std::string_view key = get("sec-websocket-key"); + const std::string_view version = get("sec-websocket-version"); + const std::string_view origin = get("origin"); + + if (!is_get || !upgrade_ws || !conn_upgrade || key.empty()) { + res.response = simple_response("400 Bad Request", "not a WebSocket upgrade"); + return res; + } + if (version != "13") { + std::string r = "HTTP/1.1 426 Upgrade Required\r\nSec-WebSocket-Version: 13\r\n"; + r.append("Connection: close\r\n\r\n"); + res.response = std::move(r); + return res; + } + if (origin.empty() || !looks_like_extension_origin(origin)) { + // docs/05 §4: verify Origin is a moz-extension origin. A page cannot pair. + res.response = simple_response("403 Forbidden", "origin not permitted"); + return res; + } + + std::string r = "HTTP/1.1 101 Switching Protocols\r\n"; + r.append("Upgrade: websocket\r\n"); + r.append("Connection: Upgrade\r\n"); + r.append("Sec-WebSocket-Accept: ").append(ws_accept_key(key)).append("\r\n\r\n"); + + res.ok = true; + res.response = std::move(r); + res.origin = std::string(origin); + return res; +} + +} // namespace velox::daemon::rpc diff --git a/daemon/src/rpc/ws_handshake.hpp b/daemon/src/rpc/ws_handshake.hpp new file mode 100644 index 0000000..c54c6a7 --- /dev/null +++ b/daemon/src/rpc/ws_handshake.hpp @@ -0,0 +1,30 @@ +#pragma once + +// The RFC 6455 opening handshake, plus the two checks the extension spec makes +// non-negotiable (docs/05 §4): the request must carry an Origin, and it must look like a +// Firefox extension origin (moz-extension://). The token check happens later, in +// session.hello — the handshake only gets the socket to WebSocket framing. + +#include +#include + +namespace velox::daemon::rpc { + +struct HandshakeResult { + bool complete = false; // a full request was parsed + bool ok = false; // ... and it is a valid, allowed upgrade + std::string response; // bytes to write back: 101 on ok, 400/403 otherwise + std::string origin; // the verified Origin, when ok + std::size_t consumed = 0; // bytes of input that formed the request +}; + +// Parse an accumulating HTTP request buffer. Returns complete=false (and consumed=0) while +// the header block is still arriving. Once "\r\n\r\n" is seen, validates and fills in the +// 101 (or an error) response. A body, if any, is not expected on an upgrade and is +// ignored. +HandshakeResult ws_try_handshake(std::string_view buffer); + +// Exposed for the unit test: RFC 6455 §1.3 accept value for a client key. +std::string ws_accept_key(std::string_view sec_websocket_key); + +} // namespace velox::daemon::rpc diff --git a/daemon/src/rpc/ws_server.cpp b/daemon/src/rpc/ws_server.cpp new file mode 100644 index 0000000..d6c701c --- /dev/null +++ b/daemon/src/rpc/ws_server.cpp @@ -0,0 +1,440 @@ +#include "rpc/ws_server.hpp" + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +#include + +#include "rpc/event_loop.hpp" +#include "rpc/ws_handshake.hpp" +#include "store/pairings.hpp" +#include "store/sqlite.hpp" +#include "version.hpp" + +namespace velox::daemon::rpc { + +namespace proto = velox::proto; +using nlohmann::json; + +namespace { + +std::error_code errc(int e) { return std::error_code(e, std::generic_category()); } + +constexpr std::size_t kMaxOutBytes = 16 * 1024 * 1024; +constexpr std::size_t kMaxHandshakeBytes = 16 * 1024; + +std::string now_iso() { + std::time_t t = std::time(nullptr); + std::tm tm{}; + ::gmtime_r(&t, &tm); + char buf[32]; + std::strftime(buf, sizeof(buf), "%Y-%m-%dT%H:%M:%SZ", &tm); + return std::string(buf); +} + +std::string uuid4() { + std::random_device rd; + std::uniform_int_distribution d; + std::uint32_t a = d(rd), b = d(rd), c = d(rd), e = d(rd); + b = (b & 0xFFFF0FFFu) | 0x00004000u; + c = (c & 0x3FFFFFFFu) | 0x80000000u; + char s[37]; + std::snprintf(s, sizeof(s), "%08x-%04x-%04x-%04x-%04x%08x", a, (b >> 16), (b & 0xFFFF), + (c >> 16), (c & 0xFFFF), e); + return std::string(s); +} + +int major_of(const std::string& semver) { + try { + return std::stoi(semver.substr(0, semver.find('.'))); + } catch (...) { + return -1; + } +} + +json rpc_error(const json& id, proto::ErrorCode code, std::string_view msg, json data = nullptr) { + return proto::make_error(id, code, msg, std::move(data)); +} + +} // namespace + +WsServer::WsServer(EventLoop& loop, proto::Dispatcher& dispatcher, store::Db& db, + PairingApprover& approver, RuntimeDir runtime) + : loop_(loop), + dispatcher_(dispatcher), + db_(db), + approver_(approver), + runtime_(std::move(runtime)) {} + +WsServer::~WsServer() { + for (auto& [fd, c] : conns_) { + loop_.del_fd(fd); + ::close(fd); + } + if (listen_fd_ >= 0) { + loop_.del_fd(listen_fd_); + ::close(listen_fd_); + } + if (wrote_port_file_) ::unlink(runtime_.ws_port_path().c_str()); +} + +std::error_code WsServer::start() { + int fd = -1; + for (int p = kPortLo; p <= kPortHi; ++p) { + fd = ::socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK | SOCK_CLOEXEC, 0); + if (fd < 0) return errc(errno); + + sockaddr_in addr{}; + addr.sin_family = AF_INET; + addr.sin_addr.s_addr = ::htonl(INADDR_LOOPBACK); // 127.0.0.1 only — never INADDR_ANY + addr.sin_port = ::htons(static_cast(p)); + + if (::bind(fd, reinterpret_cast(&addr), sizeof(addr)) == 0) { + port_ = p; + break; + } + ::close(fd); + fd = -1; + if (errno != EADDRINUSE) return errc(errno); + } + if (fd < 0) return errc(EADDRINUSE); // 52000-52016 all taken + + if (::listen(fd, SOMAXCONN) != 0) { + const int e = errno; + ::close(fd); + return errc(e); + } + + // Publish the port for the extension, which cannot read $XDG_RUNTIME_DIR itself but + // can be told where to look by a connected GUI. 0600, same as the socket. + const std::string pf = runtime_.ws_port_path(); + const int pfd = ::open(pf.c_str(), O_WRONLY | O_CREAT | O_TRUNC | O_CLOEXEC, 0600); + if (pfd < 0) { + const int e = errno; + ::close(fd); + return errc(e); + } + const std::string line = std::to_string(port_) + "\n"; + [[maybe_unused]] ssize_t w = ::write(pfd, line.data(), line.size()); + ::close(pfd); + wrote_port_file_ = true; + + listen_fd_ = fd; + loop_.add_fd(listen_fd_, kRead, [this](int, unsigned) { on_listener_readable(); }); + return {}; +} + +void WsServer::on_listener_readable() { + for (;;) { + const int cfd = ::accept4(listen_fd_, nullptr, nullptr, SOCK_NONBLOCK | SOCK_CLOEXEC); + if (cfd < 0) { + if (errno == EAGAIN || errno == EWOULDBLOCK) break; + if (errno == EINTR || errno == ECONNABORTED) continue; + break; + } + auto conn = std::make_unique(); + conn->fd = cfd; + conns_.emplace(cfd, std::move(conn)); + loop_.add_fd(cfd, kRead, [this](int fd, unsigned ev) { on_conn_event(fd, ev); }); + } +} + +void WsServer::on_conn_event(int fd, unsigned events) { + const auto it = conns_.find(fd); + if (it == conns_.end()) return; + Conn& c = *it->second; + + if (events & kWrite) { + flush(c); + if (conns_.find(fd) == conns_.end()) return; + } + if (!(events & kRead)) return; + + char buf[64 * 1024]; + for (;;) { + const ssize_t n = ::read(fd, buf, sizeof(buf)); + if (n > 0) { + const std::string_view chunk(buf, static_cast(n)); + if (c.phase == Phase::Handshake) { + c.in_raw.append(chunk); + if (c.in_raw.size() > kMaxHandshakeBytes) { + close_conn(fd); + return; + } + progress_handshake(c); + } else { + on_ws_bytes(c, chunk); + } + if (conns_.find(fd) == conns_.end()) return; + continue; + } + if (n == 0) { + close_conn(fd); + return; + } + if (errno == EAGAIN || errno == EWOULDBLOCK) break; + if (errno == EINTR) continue; + close_conn(fd); + return; + } +} + +void WsServer::progress_handshake(Conn& c) { + const HandshakeResult hs = ws_try_handshake(c.in_raw); + if (!hs.complete) return; + + c.outbuf.append(hs.response); + if (!hs.ok) { + c.close_after_flush = true; + flush(c); + return; + } + + c.origin = hs.origin; + c.phase = Phase::Open; + std::string leftover = c.in_raw.substr(hs.consumed); + c.in_raw.clear(); + flush(c); + if (conns_.count(c.fd) && !leftover.empty()) on_ws_bytes(c, leftover); +} + +void WsServer::on_ws_bytes(Conn& c, std::string_view bytes) { + std::vector msgs; + const auto st = c.frames.feed(bytes, msgs); + + for (auto& m : msgs) { + switch (m.opcode) { + case WsOpcode::Text: + handle_rpc(c, m.payload); + break; + case WsOpcode::Binary: + begin_close(c, 1003, "binary frames are not accepted"); // 1003: unacceptable data + break; + case WsOpcode::Ping: + send_frame(c, WsOpcode::Pong, m.payload); + break; + case WsOpcode::Pong: + break; + case WsOpcode::Close: + send_frame(c, WsOpcode::Close, m.payload); + c.close_after_flush = true; + flush(c); + return; + default: + break; + } + if (conns_.find(c.fd) == conns_.end()) return; + } + + if (st == WsFrameReader::Status::ProtocolError) { + begin_close(c, 1002, c.frames.error()); // 1002: protocol error + } else if (st == WsFrameReader::Status::MessageTooBig) { + begin_close(c, 1009, "message too big"); // 1009: message too big + } +} + +void WsServer::handle_rpc(Conn& c, const std::string& text) { + json req = json::parse(text, nullptr, false); + if (req.is_discarded()) { + send_text(c, rpc_error(nullptr, proto::ErrorCode::ParseError, "invalid JSON")); + return; + } + const json id = req.is_object() && req.contains("id") ? req.at("id") : json(nullptr); + const std::string method = + req.is_object() && req.contains("method") && req.at("method").is_string() + ? req.at("method").get() + : std::string{}; + + if (!method.empty()) { + json reply; + if (handle_session_ws(c, method, req, reply)) { + if (!reply.is_null()) send_text(c, reply); + return; + } + } + + // Any non-session method requires an authenticated connection. + if (!c.authed) { + send_text(c, rpc_error(id, proto::ErrorCode::NotPaired, "not paired: call session.pair first")); + return; + } + + json reply = proto::dispatch(dispatcher_, proto::Transport::Ws, req); + if (!reply.is_null()) send_text(c, reply); +} + +bool WsServer::handle_session_ws(Conn& c, const std::string& method, const json& request, + json& reply) { + const json id = request.contains("id") ? request.at("id") : json(nullptr); + const json params = request.contains("params") ? request.at("params") : json::object(); + + if (method == "session.pair") { + const auto dec = rate_limiter_.check(c.origin); + if (!dec.allowed) { + reply = rpc_error(id, proto::ErrorCode::RateLimited, + "too many pairing attempts; try again later", + json{{"retryAfterSec", dec.retry_after_sec}}); + return true; + } + auto p = proto::parse(params, "params"); + if (!p) { + reply = rpc_error(id, proto::ErrorCode::InvalidParams, p.error().message, + json{{"path", p.error().path}}); + return true; + } + + PairingRequest pr{c.origin, p->clientName, make_pairing_code()}; + if (!approver_.approve(pr)) { + rate_limiter_.record_failure(c.origin); + reply = rpc_error(id, proto::ErrorCode::NotPaired, "pairing was not approved"); + return true; + } + + store::Pairings pairings(db_); + auto created = pairings.create(c.origin, p->clientName, now_iso()); + if (!created) { + reply = rpc_error(id, proto::ErrorCode::InternalError, + "could not store the pairing: " + created.error().message); + return true; + } + rate_limiter_.record_success(c.origin); + + proto::SessionPairResult r; + r.token = created->token; + reply = proto::make_result(id, r); + return true; + } + + if (method == "session.hello") { + auto p = proto::parse(params, "params"); + if (!p) { + reply = rpc_error(id, proto::ErrorCode::InvalidParams, p.error().message, + json{{"path", p.error().path}}); + return true; + } + const int want = major_of(std::string(proto::kProtocolVersion)); + const int got = major_of(p->protocolVersion); + if (got != want) { + reply = rpc_error(id, proto::ErrorCode::VersionMismatch, + "protocol major version mismatch", + json{{"expected", std::string(proto::kProtocolVersion)}, + {"actual", p->protocolVersion}}); + c.close_after_flush = true; + return true; + } + + store::Pairings pairings(db_); + auto found = p->token ? pairings.find_active_by_token(*p->token) + : store::DbResult>(std::nullopt); + if (!found) { + reply = rpc_error(id, proto::ErrorCode::InternalError, found.error().message); + return true; + } + if (!found->has_value()) { + // Absent, malformed or wrong — all reported the same, and all count toward the + // pairing rate limit (fixture session.hello.not-paired). + rate_limiter_.record_failure(c.origin); + reply = rpc_error(id, proto::ErrorCode::NotPaired, "not paired: call session.pair first"); + return true; + } + + c.authed = true; + c.pairing_id = (*found)->pairing_id; + if (c.session_id.empty()) c.session_id = uuid4(); + (void)pairings.touch(c.pairing_id, now_iso()); + + proto::SessionHelloResult r; + r.daemonVersion = std::string(velox::daemon::kDaemonVersion); + r.protocolVersion = std::string(proto::kProtocolVersion); + r.sessionId = c.session_id; + r.transport = proto::SessionHelloResultTransport::Ws; + reply = proto::make_result(id, r); + return true; + } + + if (method == "session.subscribe") { + if (!c.authed) { + reply = rpc_error(id, proto::ErrorCode::NotPaired, "not paired: call session.pair first"); + return true; + } + auto p = proto::parse(params, "params"); + if (!p) { + reply = rpc_error(id, proto::ErrorCode::InvalidParams, p.error().message, + json{{"path", p.error().path}}); + return true; + } + proto::SessionSubscribeResult r; + r.ok = true; + for (const auto& ev : p->events) r.events.emplace_back(proto::to_string(ev)); + reply = proto::make_result(id, r); + return true; + } + + return false; +} + +void WsServer::send_text(Conn& c, const json& value) { + send_frame(c, WsOpcode::Text, value.dump()); +} + +void WsServer::send_frame(Conn& c, WsOpcode op, std::string_view payload) { + c.outbuf += ws_encode(op, payload); + if (c.outbuf.size() - c.out_off > kMaxOutBytes) { + close_conn(c.fd); + return; + } + flush(c); +} + +void WsServer::begin_close(Conn& c, std::uint16_t code, std::string_view reason) { + if (c.phase == Phase::Closing) return; + c.phase = Phase::Closing; + send_frame(c, WsOpcode::Close, ws_close_payload(code, reason)); + if (conns_.count(c.fd)) { + c.close_after_flush = true; + flush(c); + } +} + +void WsServer::flush(Conn& c) { + while (c.out_off < c.outbuf.size()) { + const ssize_t n = ::write(c.fd, c.outbuf.data() + c.out_off, c.outbuf.size() - c.out_off); + if (n > 0) { + c.out_off += static_cast(n); + continue; + } + if (n < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) { + loop_.mod_fd(c.fd, kRead | kWrite); + return; + } + if (n < 0 && errno == EINTR) continue; + close_conn(c.fd); + return; + } + c.outbuf.clear(); + c.out_off = 0; + if (c.close_after_flush) { + close_conn(c.fd); + return; + } + loop_.mod_fd(c.fd, kRead); +} + +void WsServer::close_conn(int fd) { + if (const auto it = conns_.find(fd); it != conns_.end()) { + loop_.del_fd(fd); + ::close(fd); + conns_.erase(it); + } +} + +} // namespace velox::daemon::rpc diff --git a/daemon/src/rpc/ws_server.hpp b/daemon/src/rpc/ws_server.hpp new file mode 100644 index 0000000..72e467a --- /dev/null +++ b/daemon/src/rpc/ws_server.hpp @@ -0,0 +1,92 @@ +#pragma once + +// The loopback WebSocket transport (docs/05 §4). Binds 127.0.0.1 only, on the first free +// port in 52000-52016, and writes the chosen port to /ws.port. Any local process +// can connect, so a token is mandatory: session.pair mints one (behind a user prompt, +// rate-limited), session.hello must present it, and every other method is refused -32002 +// until it does. Privileged methods are refused -32003 by the generated dispatch(). + +#include +#include +#include +#include +#include + +#include + +#include "rpc/pairing.hpp" +#include "rpc/runtime_dir.hpp" +#include "rpc/ws_frame.hpp" +#include "velox_proto.hpp" + +namespace velox::daemon::store { +class Db; +} + +namespace velox::daemon::rpc { + +class EventLoop; + +class WsServer { +public: + WsServer(EventLoop& loop, velox::proto::Dispatcher& dispatcher, velox::daemon::store::Db& db, + PairingApprover& approver, RuntimeDir runtime); + ~WsServer(); + + WsServer(const WsServer&) = delete; + WsServer& operator=(const WsServer&) = delete; + + // Pick a port, bind loopback, write ws.port, listen, register with the loop. + std::error_code start(); + + int port() const noexcept { return port_; } + std::size_t connection_count() const noexcept { return conns_.size(); } + + static constexpr int kPortLo = 52000; + static constexpr int kPortHi = 52016; + +private: + enum class Phase { Handshake, Open, Closing }; + + struct Conn { + int fd; + Phase phase = Phase::Handshake; + std::string in_raw; // bytes before the upgrade completes + WsFrameReader frames; + std::string outbuf; + std::size_t out_off = 0; + bool close_after_flush = false; + std::string origin; + bool authed = false; + std::string pairing_id; + std::string session_id; + }; + + void on_listener_readable(); + void on_conn_event(int fd, unsigned events); + void progress_handshake(Conn& c); + void on_ws_bytes(Conn& c, std::string_view bytes); + void handle_rpc(Conn& c, const std::string& text); + bool handle_session_ws(Conn& c, const std::string& method, const nlohmann::json& request, + nlohmann::json& reply); + + void send_text(Conn& c, const nlohmann::json& value); + void send_frame(Conn& c, WsOpcode op, std::string_view payload); + void begin_close(Conn& c, std::uint16_t code, std::string_view reason); + void flush(Conn& c); + void close_conn(int fd); + + EventLoop& loop_; + velox::proto::Dispatcher& dispatcher_; + velox::daemon::store::Db& db_; + PairingApprover& approver_; + RuntimeDir runtime_; + PairingRateLimiter rate_limiter_; + + int listen_fd_ = -1; + int port_ = 0; + bool wrote_port_file_ = false; + std::unordered_map> conns_; +}; + +} // namespace velox::daemon::rpc diff --git a/daemon/src/store/pairings.cpp b/daemon/src/store/pairings.cpp new file mode 100644 index 0000000..743a17e --- /dev/null +++ b/daemon/src/store/pairings.cpp @@ -0,0 +1,104 @@ +#include "store/pairings.hpp" + +#include + +#include + +#include "util/crypto.hpp" + +namespace velox::daemon::store { + +namespace { + +std::string uuid4() { + std::random_device rd; + std::uniform_int_distribution d; + std::uint32_t a = d(rd), b = d(rd), c = d(rd), e = d(rd); + b = (b & 0xFFFF0FFFu) | 0x00004000u; + c = (c & 0x3FFFFFFFu) | 0x80000000u; + char buf[37]; + std::snprintf(buf, sizeof(buf), "%08x-%04x-%04x-%04x-%04x%08x", a, (b >> 16), (b & 0xFFFF), + (c >> 16), (c & 0xFFFF), e); + return std::string(buf); +} + +Pairing read_row(Stmt& s) { + Pairing p; + p.pairing_id = s.column_text(0); + p.origin = s.column_text(1); + p.label = s.column_text(2); + p.created_at = s.column_text(3); + if (!s.column_is_null(4)) p.last_seen_at = s.column_text(4); + if (!s.column_is_null(5)) p.revoked_at = s.column_text(5); + return p; +} + +constexpr std::string_view kCols = + "pairing_id, origin, label, created_at, last_seen_at, revoked_at"; + +} // namespace + +DbResult Pairings::create(std::string_view origin, std::string_view label, + std::string_view now_iso) { + Created out{uuid4(), velox::daemon::crypto::random_token(32)}; + const std::string hash = velox::daemon::crypto::sha256_hex(out.token); + + auto st = db_.prepare( + "INSERT INTO pairings(pairing_id, token_sha256, origin, label, created_at) " + "VALUES(?1, ?2, ?3, ?4, ?5)"); + if (!st) return std::unexpected(st.error()); + if (auto r = st->bind(1, out.pairing_id); !r) return std::unexpected(r.error()); + if (auto r = st->bind(2, std::string_view(hash)); !r) return std::unexpected(r.error()); + if (auto r = st->bind(3, origin); !r) return std::unexpected(r.error()); + if (auto r = st->bind(4, label); !r) return std::unexpected(r.error()); + if (auto r = st->bind(5, now_iso); !r) return std::unexpected(r.error()); + if (auto r = st->step(); !r) return std::unexpected(r.error()); + return out; +} + +DbResult> Pairings::find_active_by_token(std::string_view token) { + const std::string hash = velox::daemon::crypto::sha256_hex(token); + auto st = db_.prepare(std::string("SELECT ").append(kCols).append( + " FROM pairings WHERE token_sha256 = ?1 AND revoked_at IS NULL")); + if (!st) return std::unexpected(st.error()); + if (auto r = st->bind(1, std::string_view(hash)); !r) return std::unexpected(r.error()); + auto row = st->step(); + if (!row) return std::unexpected(row.error()); + if (!*row) return std::optional{}; + return std::optional{read_row(*st)}; +} + +DbResult Pairings::touch(std::string_view pairing_id, std::string_view now_iso) { + auto st = db_.prepare("UPDATE pairings SET last_seen_at = ?2 WHERE pairing_id = ?1"); + if (!st) return std::unexpected(st.error()); + if (auto r = st->bind(1, pairing_id); !r) return std::unexpected(r.error()); + if (auto r = st->bind(2, now_iso); !r) return std::unexpected(r.error()); + if (auto r = st->step(); !r) return std::unexpected(r.error()); + return {}; +} + +DbResult Pairings::revoke(std::string_view pairing_id, std::string_view now_iso) { + auto st = db_.prepare( + "UPDATE pairings SET revoked_at = ?2 WHERE pairing_id = ?1 AND revoked_at IS NULL"); + if (!st) return std::unexpected(st.error()); + if (auto r = st->bind(1, pairing_id); !r) return std::unexpected(r.error()); + if (auto r = st->bind(2, now_iso); !r) return std::unexpected(r.error()); + if (auto r = st->step(); !r) return std::unexpected(r.error()); + return sqlite3_changes(db_.raw()) > 0; +} + +DbResult> Pairings::list_active() { + auto st = db_.prepare(std::string("SELECT ").append(kCols).append( + " FROM pairings WHERE revoked_at IS NULL ORDER BY created_at")); + if (!st) return std::unexpected(st.error()); + std::vector out; + for (;;) { + auto row = st->step(); + if (!row) return std::unexpected(row.error()); + if (!*row) break; + out.push_back(read_row(*st)); + } + return out; +} + +} // namespace velox::daemon::store diff --git a/daemon/src/store/pairings.hpp b/daemon/src/store/pairings.hpp new file mode 100644 index 0000000..ad95ad5 --- /dev/null +++ b/daemon/src/store/pairings.hpp @@ -0,0 +1,53 @@ +#pragma once + +// Access to the `pairings` table: the WebSocket transport's revocable per-install tokens +// (docs/05 §4). The plaintext token is returned by create() exactly once and never +// stored — only its SHA-256 (CLAUDE.md §4). + +#include +#include +#include +#include + +#include "store/sqlite.hpp" + +namespace velox::daemon::store { + +struct Pairing { + std::string pairing_id; + std::string origin; + std::string label; + std::string created_at; + std::optional last_seen_at; + std::optional revoked_at; +}; + +class Pairings { +public: + explicit Pairings(Db& db) : db_(db) {} + + struct Created { + std::string pairing_id; + std::string token; // plaintext — send once, to the client, then forget + }; + + // Mint a token for `origin`, store its hash + `label`, timestamp `now_iso`. + DbResult create(std::string_view origin, std::string_view label, + std::string_view now_iso); + + // The active (non-revoked) pairing whose token hashes to this value, if any. + DbResult> find_active_by_token(std::string_view plaintext_token); + + // Bump last_seen_at. Called on every authenticated connect. + DbResult touch(std::string_view pairing_id, std::string_view now_iso); + + // Mark revoked. Returns false if there was no such active pairing. + DbResult revoke(std::string_view pairing_id, std::string_view now_iso); + + DbResult> list_active(); + +private: + Db& db_; +}; + +} // namespace velox::daemon::store diff --git a/daemon/src/store/sqlite.cpp b/daemon/src/store/sqlite.cpp index 136d60e..1dd1726 100644 --- a/daemon/src/store/sqlite.cpp +++ b/daemon/src/store/sqlite.cpp @@ -2,6 +2,8 @@ #include +#include + #include namespace velox::daemon::store { @@ -38,6 +40,13 @@ DbResult Db::open(const std::string& path) { } Db db(handle); + // Not a secret store (credentials go to the Secret Service), but task URLs and pairing + // hashes still are not world-readable. SQLite honours the umask; pin 0600 explicitly. + if (path != ":memory:" && !path.empty() && path.front() != ':') { + ::chmod(path.c_str(), 0600); + ::chmod((path + "-wal").c_str(), 0600); + ::chmod((path + "-shm").c_str(), 0600); + } // WAL for crash-safe concurrent readers (docs/01 §1). busy_timeout so a writer waits // rather than returning SQLITE_BUSY under the RPC loop. foreign_keys is per-connection. for (const char* pragma : {"PRAGMA journal_mode=WAL", "PRAGMA synchronous=NORMAL", diff --git a/daemon/src/util/crypto.cpp b/daemon/src/util/crypto.cpp new file mode 100644 index 0000000..39b1b7c --- /dev/null +++ b/daemon/src/util/crypto.cpp @@ -0,0 +1,57 @@ +#include "util/crypto.hpp" + +#include +#include +#include + +#include + +namespace velox::daemon::crypto { + +std::array sha1(std::string_view data) { + std::array out{}; + ::SHA1(reinterpret_cast(data.data()), data.size(), out.data()); + return out; +} + +std::string sha256_hex(std::string_view data) { + unsigned char digest[SHA256_DIGEST_LENGTH]; + ::SHA256(reinterpret_cast(data.data()), data.size(), digest); + + static constexpr char kHex[] = "0123456789abcdef"; + std::string out; + out.reserve(SHA256_DIGEST_LENGTH * 2); + for (unsigned char b : digest) { + out.push_back(kHex[b >> 4]); + out.push_back(kHex[b & 0x0F]); + } + return out; +} + +std::string base64_encode(const std::uint8_t* data, std::size_t len) { + // 4 chars per 3 bytes, rounded up, plus a NUL that EVP writes. + std::string out(4 * ((len + 2) / 3), '\0'); + const int n = ::EVP_EncodeBlock(reinterpret_cast(out.data()), data, + static_cast(len)); + if (n < 0) throw std::runtime_error("EVP_EncodeBlock failed"); + out.resize(static_cast(n)); + return out; +} + +std::string random_token(std::size_t n) { + std::string raw(n, '\0'); + if (::RAND_bytes(reinterpret_cast(raw.data()), static_cast(n)) != 1) { + throw std::runtime_error("RAND_bytes failed"); + } + std::string b64 = + base64_encode(reinterpret_cast(raw.data()), raw.size()); + // base64 -> base64url, and drop '=' padding. + for (char& c : b64) { + if (c == '+') c = '-'; + else if (c == '/') c = '_'; + } + while (!b64.empty() && b64.back() == '=') b64.pop_back(); + return b64; +} + +} // namespace velox::daemon::crypto diff --git a/daemon/src/util/crypto.hpp b/daemon/src/util/crypto.hpp new file mode 100644 index 0000000..0d31ec1 --- /dev/null +++ b/daemon/src/util/crypto.hpp @@ -0,0 +1,29 @@ +#pragma once + +// Small cryptographic helpers over libcrypto: the WebSocket accept-key hash, the pairing +// token hash, base64, and a CSPRNG token. Nothing bespoke — thin wrappers so callers do +// not touch the OpenSSL API directly. + +#include +#include +#include +#include +#include + +namespace velox::daemon::crypto { + +// SHA-1 of `data`, raw 20 bytes. Used only for the RFC 6455 Sec-WebSocket-Accept value. +std::array sha1(std::string_view data); + +// SHA-256 of `data` as lowercase hex (64 chars). The pairings table stores this, never +// the token itself (CLAUDE.md §4). +std::string sha256_hex(std::string_view data); + +// Standard base64 (with '+' '/' '='), used for Sec-WebSocket-Accept. +std::string base64_encode(const std::uint8_t* data, std::size_t len); + +// `n` bytes from the system CSPRNG, encoded base64url without padding. The pairing token +// is 32 bytes -> 43 chars, matching SessionPairResult.token's "256 bits, base64url". +std::string random_token(std::size_t n = 32); + +} // namespace velox::daemon::crypto diff --git a/daemon/tests/CMakeLists.txt b/daemon/tests/CMakeLists.txt index fa8ee78..cd7269f 100644 --- a/daemon/tests/CMakeLists.txt +++ b/daemon/tests/CMakeLists.txt @@ -1,19 +1,18 @@ # daemon unit + integration tests. Registered with ctest; run via `ctest --preset dev`. -# No external test framework — each file is a small self-checking binary (matches the -# lightweight style core/ uses, without depending on core's private test support). +# No external test framework — each file is a small self-checking binary. -add_executable(veloxd_ndjson_test ndjson_test.cpp) -target_link_libraries(veloxd_ndjson_test PRIVATE veloxd_rpc) -target_compile_options(veloxd_ndjson_test PRIVATE -Wall -Wextra -Wpedantic -Werror) -add_test(NAME veloxd.ndjson COMMAND veloxd_ndjson_test) +function(veloxd_test name) + cmake_parse_arguments(T "" "" "LIBS" ${ARGN}) + add_executable(veloxd_${name}_test ${name}_test.cpp) + target_link_libraries(veloxd_${name}_test PRIVATE ${T_LIBS}) + target_compile_options(veloxd_${name}_test PRIVATE -Wall -Wextra -Wpedantic -Werror) + add_test(NAME veloxd.${name} COMMAND veloxd_${name}_test) + set_tests_properties(veloxd.${name} PROPERTIES TIMEOUT 30) +endfunction() -add_executable(veloxd_uds_roundtrip_test uds_roundtrip_test.cpp) -target_link_libraries(veloxd_uds_roundtrip_test PRIVATE veloxd_rpc) -target_compile_options(veloxd_uds_roundtrip_test PRIVATE -Wall -Wextra -Wpedantic -Werror) -add_test(NAME veloxd.uds_roundtrip COMMAND veloxd_uds_roundtrip_test) -set_tests_properties(veloxd.uds_roundtrip PROPERTIES TIMEOUT 30) - -add_executable(veloxd_store_migrations_test store_migrations_test.cpp) -target_link_libraries(veloxd_store_migrations_test PRIVATE veloxd_store) -target_compile_options(veloxd_store_migrations_test PRIVATE -Wall -Wextra -Wpedantic -Werror) -add_test(NAME veloxd.store_migrations COMMAND veloxd_store_migrations_test) +veloxd_test(ndjson LIBS veloxd_rpc) +veloxd_test(uds_roundtrip LIBS veloxd_rpc) +veloxd_test(store_migrations LIBS veloxd_store) +veloxd_test(pairings LIBS veloxd_store veloxd_rpc) +veloxd_test(ws_frame LIBS veloxd_rpc) +veloxd_test(ws_server LIBS veloxd_rpc) diff --git a/daemon/tests/pairings_test.cpp b/daemon/tests/pairings_test.cpp new file mode 100644 index 0000000..2028e71 --- /dev/null +++ b/daemon/tests/pairings_test.cpp @@ -0,0 +1,87 @@ +// The pairings table + the pairing rate limiter. + +#include + +#include "check.hpp" +#include "rpc/pairing.hpp" +#include "store/migrations.hpp" +#include "store/pairings.hpp" +#include "store/sqlite.hpp" + +using namespace velox::daemon; + +void run() { + // --- store: create -> find-by-token -> revoke --------------------------------- + { + auto db = store::Db::open(":memory:"); + CHECK(db.has_value()); + if (!db) return; + CHECK(store::migrate_to_head(*db).has_value()); + + store::Pairings p(*db); + auto created = p.create("moz-extension://abc", "Velox for Firefox", "2026-09-10T00:00:00Z"); + CHECK(created.has_value()); + if (!created) return; + CHECK(created->token.size() >= 40); // 32 bytes base64url, unpadded + CHECK(!created->pairing_id.empty()); + + // The plaintext token is not in the DB — only its hash. + auto st = db->prepare("SELECT count(*) FROM pairings WHERE token_sha256 = ?1"); + CHECK(st.has_value()); + CHECK(st->bind(1, std::string_view(created->token)).has_value()); + auto row = st->step(); + CHECK(row.has_value() && *row); + CHECK_EQ(st->column_int(0), 0); // token itself never stored + + auto found = p.find_active_by_token(created->token); + CHECK(found.has_value()); + CHECK(found->has_value()); + if (found && *found) CHECK_EQ((*found)->origin, std::string("moz-extension://abc")); + + auto missing = p.find_active_by_token("not-the-token"); + CHECK(missing.has_value() && !missing->has_value()); + + auto revoked = p.revoke(created->pairing_id, "2026-09-10T01:00:00Z"); + CHECK(revoked.has_value() && *revoked == true); + + auto after = p.find_active_by_token(created->token); + CHECK(after.has_value() && !after->has_value()); // revoked -> not active + + auto revoke_again = p.revoke(created->pairing_id, "2026-09-10T02:00:00Z"); + CHECK(revoke_again.has_value() && *revoke_again == false); + } + + // --- rate limiter: 5 failures, then a lockout with retryAfter ----------------- + { + rpc::PairingRateLimiter rl; + using Clock = rpc::PairingRateLimiter::Clock; + const auto t0 = Clock::now(); + + for (int i = 0; i < 5; ++i) { + CHECK(rl.check("origin-a", t0).allowed); + rl.record_failure("origin-a", t0); + } + const auto d = rl.check("origin-a", t0); + CHECK(!d.allowed); + CHECK(d.retry_after_sec > 0 && d.retry_after_sec <= 61); + + // A different origin is unaffected — the lockout is per-origin. + CHECK(rl.check("origin-b", t0).allowed); + + // Still locked 30 s later; clear after the lockout elapses. + CHECK(!rl.check("origin-a", t0 + std::chrono::seconds(30)).allowed); + CHECK(rl.check("origin-a", t0 + std::chrono::seconds(121)).allowed); + + // A success wipes the origin's history. + rl.record_failure("origin-c", t0); + rl.record_failure("origin-c", t0); + rl.record_success("origin-c"); + for (int i = 0; i < 4; ++i) { + CHECK(rl.check("origin-c", t0).allowed); + rl.record_failure("origin-c", t0); + } + CHECK(rl.check("origin-c", t0).allowed); // only 4 since the reset + } +} + +TEST_MAIN() diff --git a/daemon/tests/ws_frame_test.cpp b/daemon/tests/ws_frame_test.cpp new file mode 100644 index 0000000..a3283b8 --- /dev/null +++ b/daemon/tests/ws_frame_test.cpp @@ -0,0 +1,150 @@ +#include "rpc/ws_frame.hpp" +#include "rpc/ws_handshake.hpp" + +#include +#include + +#include "check.hpp" + +using namespace velox::daemon::rpc; + +namespace { + +// Build a *client* frame: FIN/opcode, mask bit set, a fixed 4-byte mask, masked payload. +std::string client_frame(WsOpcode op, std::string_view payload, bool fin = true) { + std::string f; + f.push_back(static_cast((fin ? 0x80 : 0x00) | static_cast(op))); + const std::size_t n = payload.size(); + if (n < 126) { + f.push_back(static_cast(0x80 | n)); + } else if (n <= 0xFFFF) { + f.push_back(static_cast(0x80 | 126)); + f.push_back(static_cast((n >> 8) & 0xFF)); + f.push_back(static_cast(n & 0xFF)); + } else { + f.push_back(static_cast(0x80 | 127)); + for (int i = 7; i >= 0; --i) + f.push_back(static_cast((static_cast(n) >> (i * 8)) & 0xFF)); + } + const char key[4] = {0x12, 0x34, 0x56, 0x78}; + f.append(key, 4); + for (std::size_t i = 0; i < n; ++i) f.push_back(static_cast(payload[i] ^ key[i & 3])); + return f; +} + +} // namespace + +void run() { + // --- one text frame ------------------------------------------------------------ + { + WsFrameReader r; + std::vector m; + CHECK(r.feed(client_frame(WsOpcode::Text, "{\"a\":1}"), m) == WsFrameReader::Status::Ok); + CHECK_EQ(m.size(), 1u); + CHECK(m[0].opcode == WsOpcode::Text); + CHECK_EQ(m[0].payload, std::string("{\"a\":1}")); + } + + // --- fragmented: text (fin=0) + continuation (fin=1) -------------------------- + { + WsFrameReader r; + std::vector m; + r.feed(client_frame(WsOpcode::Text, "hel", /*fin=*/false), m); + CHECK_EQ(m.size(), 0u); + r.feed(client_frame(WsOpcode::Continuation, "lo", /*fin=*/true), m); + CHECK_EQ(m.size(), 1u); + CHECK_EQ(m[0].payload, std::string("hello")); + } + + // --- byte-at-a-time delivery still reassembles ------------------------------- + { + WsFrameReader r; + std::vector m; + const std::string frame = client_frame(WsOpcode::Text, "streamed"); + for (char ch : frame) r.feed(std::string_view(&ch, 1), m); + CHECK_EQ(m.size(), 1u); + CHECK_EQ(m[0].payload, std::string("streamed")); + } + + // --- a 200-byte payload exercises the 16-bit length path -------------------- + { + WsFrameReader r; + std::vector m; + const std::string big(200, 'x'); + r.feed(client_frame(WsOpcode::Text, big), m); + CHECK_EQ(m.size(), 1u); + CHECK_EQ(m[0].payload.size(), 200u); + } + + // --- ping is surfaced so the server can pong ------------------------------- + { + WsFrameReader r; + std::vector m; + r.feed(client_frame(WsOpcode::Ping, "hi"), m); + CHECK_EQ(m.size(), 1u); + CHECK(m[0].opcode == WsOpcode::Ping); + } + + // --- an unmasked client frame is a protocol error (RFC 6455 §5.1) ---------- + { + WsFrameReader r; + std::vector m; + std::string bad; + bad.push_back(static_cast(0x81)); // FIN + text + bad.push_back(static_cast(0x03)); // len 3, mask bit clear + bad.append("abc"); + CHECK(r.feed(bad, m) == WsFrameReader::Status::ProtocolError); + } + + // --- a declared length past the cap is rejected before allocating ---------- + { + WsFrameReader r; + std::vector m; + std::string hdr; + hdr.push_back(static_cast(0x82)); // FIN + binary + hdr.push_back(static_cast(0x80 | 127)); + for (int i = 7; i >= 0; --i) + hdr.push_back(static_cast((0x0000000001000000ull >> (i * 8)) & 0xFF)); // 16 MiB + CHECK(r.feed(hdr, m) == WsFrameReader::Status::MessageTooBig); + } + + // --- ws_encode: server frames are unmasked, correct length byte ------------ + { + const std::string f = ws_encode(WsOpcode::Text, "abc"); + CHECK_EQ(static_cast(f[0]), 0x81u); + CHECK_EQ(static_cast(f[1]), 0x03u); // len 3, no mask bit + CHECK_EQ(f.substr(2), std::string("abc")); + } + + // --- RFC 6455 §1.3 sample accept value ------------------------------------ + CHECK_EQ(ws_accept_key("dGhlIHNhbXBsZSBub25jZQ=="), + std::string("s3pPLMBiTxaQ9kYGzzhZRbK+xOo=")); + + // --- handshake: a page origin is refused, an extension origin upgrades ----- + { + const std::string req_page = + "GET / HTTP/1.1\r\nHost: 127.0.0.1:52000\r\nUpgrade: websocket\r\n" + "Connection: Upgrade\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n" + "Sec-WebSocket-Version: 13\r\nOrigin: https://evil.example\r\n\r\n"; + const auto r = ws_try_handshake(req_page); + CHECK(r.complete); + CHECK(!r.ok); + CHECK(r.response.find("403") != std::string::npos); + } + { + const std::string req_ext = + "GET / HTTP/1.1\r\nHost: 127.0.0.1:52000\r\nUpgrade: websocket\r\n" + "Connection: Upgrade\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n" + "Sec-WebSocket-Version: 13\r\n" + "Origin: moz-extension://11111111-2222-3333-4444-555555555555\r\n\r\n"; + const auto r = ws_try_handshake(req_ext); + CHECK(r.complete); + CHECK(r.ok); + CHECK(r.response.find("101") != std::string::npos); + CHECK(r.response.find("Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=") != + std::string::npos); + CHECK_EQ(r.origin, std::string("moz-extension://11111111-2222-3333-4444-555555555555")); + } +} + +TEST_MAIN() diff --git a/daemon/tests/ws_server_test.cpp b/daemon/tests/ws_server_test.cpp new file mode 100644 index 0000000..e241012 --- /dev/null +++ b/daemon/tests/ws_server_test.cpp @@ -0,0 +1,216 @@ +// Integration: a real WsServer on a loopback port, a hand-rolled WebSocket client. +// Covers the handshake, the pairing flow, the token gate (-32002), and the +// privileged-over-WS refusal (-32003). + +#include +#include +#include +#include + +#include +#include +#include +#include + +#include + +#include "check.hpp" +#include "rpc/dispatcher.hpp" +#include "rpc/event_loop.hpp" +#include "rpc/pairing.hpp" +#include "rpc/runtime_dir.hpp" +#include "rpc/ws_frame.hpp" +#include "rpc/ws_server.hpp" +#include "store/migrations.hpp" +#include "store/sqlite.hpp" + +using nlohmann::json; +namespace rpc = velox::daemon::rpc; +namespace store = velox::daemon::store; + +namespace { + +int dial(int port) { + const int fd = ::socket(AF_INET, SOCK_STREAM | SOCK_CLOEXEC, 0); + sockaddr_in a{}; + a.sin_family = AF_INET; + a.sin_addr.s_addr = ::htonl(INADDR_LOOPBACK); + a.sin_port = ::htons(static_cast(port)); + if (::connect(fd, reinterpret_cast(&a), sizeof(a)) != 0) { + ::close(fd); + return -1; + } + return fd; +} + +void write_all(int fd, std::string_view 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; + off += static_cast(n); + } +} + +std::string read_some(int fd) { + char buf[8192]; + const ssize_t n = ::read(fd, buf, sizeof(buf)); + return n > 0 ? std::string(buf, static_cast(n)) : std::string{}; +} + +// A masked client text frame. +std::string client_text(std::string_view payload) { + std::string f; + f.push_back(static_cast(0x81)); // FIN + text + const std::size_t n = payload.size(); + if (n < 126) { + f.push_back(static_cast(0x80 | n)); + } else { + f.push_back(static_cast(0x80 | 126)); + f.push_back(static_cast((n >> 8) & 0xFF)); + f.push_back(static_cast(n & 0xFF)); + } + const char k[4] = {0x0A, 0x0B, 0x0C, 0x0D}; + f.append(k, 4); + for (std::size_t i = 0; i < n; ++i) f.push_back(static_cast(payload[i] ^ k[i & 3])); + return f; +} + +// Decode one unmasked server frame from `buf`, consuming it. Returns payload; sets `op`. +std::string server_frame(std::string& buf, rpc::WsOpcode& op) { + if (buf.size() < 2) return {}; + op = static_cast(buf[0] & 0x0F); + std::size_t len = static_cast(buf[1]) & 0x7F; + std::size_t header = 2; + if (len == 126) { + len = (static_cast(static_cast(buf[2])) << 8) | + static_cast(buf[3]); + header = 4; + } + if (buf.size() < header + len) return {}; + std::string payload = buf.substr(header, len); + buf.erase(0, header + len); + return payload; +} + +// Send a request frame, wait for one text reply, return its parsed JSON. +json rpc_call(int fd, const json& req) { + write_all(fd, client_text(req.dump())); + std::string buf; + for (;;) { + buf += read_some(fd); + rpc::WsOpcode op{}; + std::string save = buf; + const std::string payload = server_frame(buf, op); + if (payload.empty() && buf == save) continue; // need more bytes + if (op == rpc::WsOpcode::Text) return json::parse(payload, nullptr, false); + } +} + +} // namespace + +void run() { + ::unsetenv("VELOX_PAIR_AUTO"); + + auto db = store::Db::open(":memory:"); + CHECK(db.has_value()); + if (!db) return; + CHECK(store::migrate_to_head(*db).has_value()); + + char tmpl[] = "/tmp/velox-ws-test-XXXXXX"; + const char* dir = ::mkdtemp(tmpl); + CHECK(dir != nullptr); + rpc::RuntimeDir rt{dir ? dir : "/tmp"}; + + rpc::EventLoop loop; + rpc::VeloxDispatcher dispatcher; + rpc::EnvAutoApprover approver; + rpc::WsServer server(loop, dispatcher, *db, approver, rt); + const auto ec = server.start(); + CHECK(!ec); + if (ec) return; + CHECK(server.port() >= rpc::WsServer::kPortLo); + CHECK(server.port() <= rpc::WsServer::kPortHi); + + std::thread th([&loop] { loop.run(); }); + + const std::string origin = "moz-extension://11111111-2222-3333-4444-555555555555"; + + // --- handshake --------------------------------------------------------------- + const int fd = dial(server.port()); + CHECK(fd >= 0); + write_all(fd, + "GET / HTTP/1.1\r\nHost: 127.0.0.1\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n" + "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Version: 13\r\n" + "Origin: " + origin + "\r\n\r\n"); + std::string hs; + while (hs.find("\r\n\r\n") == std::string::npos) hs += read_some(fd); + CHECK(hs.find("101 Switching Protocols") != std::string::npos); + + // --- session.hello with no token -> -32002 -------------------------------- + { + const json r = rpc_call(fd, {{"jsonrpc", "2.0"}, {"id", 1}, {"method", "session.hello"}, + {"params", {{"clientType", "extension"}, + {"clientName", "Velox for Firefox"}, + {"protocolVersion", + std::string(velox::proto::kProtocolVersion)}}}}); + CHECK(r.contains("error")); + CHECK_EQ(r["error"]["code"].get(), -32002); + } + + // --- session.pair with approval off -> not approved ---------------------- + { + const json r = rpc_call(fd, {{"jsonrpc", "2.0"}, {"id", 2}, {"method", "session.pair"}, + {"params", {{"clientName", "Velox for Firefox"}, + {"extensionId", "11111111-2222-3333-4444-555555555555"}}}}); + CHECK(r.contains("error")); + } + + // --- approval on -> a token, then hello with it succeeds ---------------- + ::setenv("VELOX_PAIR_AUTO", "1", 1); + std::string token; + { + const json r = rpc_call(fd, {{"jsonrpc", "2.0"}, {"id", 3}, {"method", "session.pair"}, + {"params", {{"clientName", "Velox for Firefox"}, + {"extensionId", "11111111-2222-3333-4444-555555555555"}}}}); + CHECK(r.contains("result")); + if (r.contains("result")) { + token = r["result"]["token"].get(); + CHECK(token.size() >= 40); + } + } + { + const json r = rpc_call(fd, {{"jsonrpc", "2.0"}, {"id", 4}, {"method", "session.hello"}, + {"params", {{"clientType", "extension"}, + {"clientName", "Velox for Firefox"}, + {"protocolVersion", + std::string(velox::proto::kProtocolVersion)}, + {"token", token}}}}); + CHECK(r.contains("result")); + if (r.contains("result")) + CHECK_EQ(r["result"]["transport"].get(), std::string("ws")); + } + + // --- privileged method over WS -> -32003 ------------------------------- + { + const json r = rpc_call(fd, {{"jsonrpc", "2.0"}, {"id", 5}, {"method", "settings.get"}, + {"params", {{"keys", nullptr}}}}); + CHECK(r.contains("error")); + CHECK_EQ(r["error"]["code"].get(), -32003); + } + + // --- a non-privileged method while authed -> a real result ----------- + { + const json r = rpc_call(fd, {{"jsonrpc", "2.0"}, {"id", 6}, {"method", "download.list"}, + {"params", json::object()}}); + CHECK(r.contains("result")); + if (r.contains("result")) CHECK_EQ(r["result"]["total"].get(), 0); + } + + ::close(fd); + loop.stop(); + th.join(); + ::unlink(rt.ws_port_path().c_str()); +} + +TEST_MAIN() From 207acb0c00f882f628985a859d811c6bba4d258b Mon Sep 17 00:00:00 2001 From: sami Date: Thu, 10 Sep 2026 15:46:16 +0400 Subject: [PATCH 6/7] =?UTF-8?q?daemon:=20review=20CORE's=20engine=20API=20?= =?UTF-8?q?=E2=80=94=20sign=20off,=20answer=20the=20five=20questions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of core/docs/engine-api-m1.md (lane/core@d6cf1fe) in daemon/docs/engine-api-review.md. Sign off: nothing forces a sched/ or RPC-dispatch rewrite; the split matches ADR 0011 and ADR 0013. Answers: (1) keep probe_hint optional — DAEMON has a ProbeResult only on the File Info path; (2) one cancel(discard_partial), download.remove is cancel(true) + DAEMON-side row/file cleanup; (3) {restart, keep_partial, abort} suffices if the engine owns the mechanical 416 re-probe/re-split; (4) per-task 4 Hz progress is fine — DAEMON re-batches across tasks for event.task.progress anyway; (5) refresh_url restarts all segments on the new URL (the signed-URL case), mirror rotation is spec.mirrors not refresh_url. Four things to confirm, none blocking: vdm::TaskId copy/hash semantics and that DAEMON never constructs one; who mkdir -p's save_path's parent; sha512 (in the wire Checksum, not the engine enum) rejected at the RPC edge; on_finished(Err{cancelled}) code + ordering vs on_state(_, cancelled, _). Integration timing: wire after sched/ lands. sched/ builds against these signatures in parallel with CORE stage 8. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Upd9WhG9oppieig5nRDLig --- daemon/docs/engine-api-review.md | 90 ++++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 daemon/docs/engine-api-review.md diff --git a/daemon/docs/engine-api-review.md b/daemon/docs/engine-api-review.md new file mode 100644 index 0000000..b907e91 --- /dev/null +++ b/daemon/docs/engine-api-review.md @@ -0,0 +1,90 @@ +# DAEMON review — CORE's engine API (`core/docs/engine-api-m1.md`, `lane/core@d6cf1fe`) + +**Verdict: sign off.** Nothing here forces a `daemon/src/sched/` or RPC-dispatch rewrite. +The value types are final enough to build `sched/` against now; `Engine` / `DownloadHandle` +bodies landing in stage 8 is fine. The split matches ADR 0011 and ADR 0013 exactly. + +Answers to the five open questions, then the small things to confirm. + +## Answers + +### Q1 — `probe_hint`: keep it optional, as sketched + +DAEMON has a `ProbeResult` on exactly one path: the File Info dialog, where the user +already waited for `download.probe` and then clicked Download. Every other entry — +`capture.offer` → take, `velox add`, `download.addBatch`, the restart flow — has no probe +in hand. Forcing the engine to always probe adds a round trip to the one case where the +user just sat through one; forcing DAEMON to always probe first means reimplementing the +engine's probe on the daemon side. Pass `probe_hint` when we have it, omit it otherwise — +the two code paths are worth keeping. + +### Q2 — one `cancel(discard_partial)`, not a separate `remove()` + +The two wire methods map cleanly onto the one call: + +| wire | live task | already terminal | +|---|---|---| +| `download.cancel` | `handle.cancel(discard_partial=false)` — keeps `.veloxpart` | no-op on the handle; DAEMON marks the row `cancelled` | +| `download.remove {deleteFile}` | `handle.cancel(discard_partial=true)` | no-op on the handle; DAEMON deletes the row and, if `deleteFile`, the finished file | + +`download.remove` on a completed task is pure DAEMON-side work (row + optional file); the +handle is already terminal so `cancel()` no-ops, which is exactly what we want. No +`handle.remove()` needed. + +### Q3 — `{restart, keep_partial, abort}` is enough, if the engine owns the mechanical 416 retry + +For `server_file_changed` the three options are right and complete. For +`range_metadata_stale` (416): `docs/04` §7 already has the engine re-probe and re-split +automatically. Keep that — DAEMON does not want to be in the loop for a routine 416. Only +escalate to `on_decision_needed{range_metadata_stale}` when the automatic re-probe/re-split +*also* fails to reconcile, and at that point "retry the same range once more" is not a +useful fourth option (the engine already exhausted it). So: no fourth value, provided the +engine handles the common 416 without a callback. + +### Q4 — per-task 4 Hz `on_progress` is fine; `on_progress_batch` optional + +DAEMON already has to coalesce across tasks: `event.task.progress` is "batched ≤ 4 Hz into +a single array message" (AGENT-DAEMON.md item 5). So 80 per-task callbacks/s land in +DAEMON's fan-out queue and are re-emitted as one array at 4 Hz regardless. Per-task keeps +the handle↔callback correspondence simple and is not a bottleneck. If the engine's timer +thread is already walking every task to build those callbacks, a +`on_progress_batch(span)` is strictly less work for both sides and we'd take it — +but it is not needed for M1 and should not hold stage 8. + +### Q5 — `refresh_url` while `downloading`: restart all segments on the new URL + +`download.refreshUrl`'s contract is the signed-URL-expiry case: "re-probes and compares +size and validator; if they still match, the transfer resumes from where it stopped." That +wants consistency — every segment on the new URL once the re-probe validates. Mirror +rotation (finish in-flight on the old host, new work elsewhere) is a different mechanism +and it is already `DownloadSpec.mirrors` + the segmenter's 3-failure requeue, not +`refresh_url`. So: on `refresh_url`, re-probe, and if size+validator match, move all +segments to the new URL from their current offsets; if they don't match, surface it +(`on_decision_needed` or a `refresh_url` error) rather than silently restarting. + +## To confirm — none block sign-off + +1. **`vdm::TaskId` ↔ wire UUID.** `sched/` keeps a `vdm::TaskId → wire taskId` map, keyed + off `handle.id()` at `start()`. Confirm `vdm::TaskId` is cheap to copy and hashable + (`std::unordered_map` key), and that DAEMON is never expected to *construct* one — it + only ever receives them from `start()` / callbacks and passes them back to + `set_task_order()`. +2. **Parent directory creation.** The engine "never canonicalises or root-checks" + `save_path` — does it `mkdir -p` the parent (the category folder may not exist yet), or + must DAEMON ensure the directory exists before `start()`? DAEMON assumes the latter + (it owns category/path logic) unless you say otherwise. +3. **`Checksum::Algo` has no `sha512`**, but `Checksum.schema.json` accepts it (PROTO F3). + DAEMON will reject `algorithm: "sha512"` at the RPC edge with `-32602` before `start()` + unless CORE would rather carry a fourth enum value. Either is fine; say which. +4. **`on_finished(Err{cancelled})`** — DAEMON keys on `ErrorInfo.code == cancelled` to + write a `cancelled` history row rather than surfacing it as a user-facing failure. + Confirm `cancelled` is the code and that `on_state(_, cancelled, nullopt)` always + precedes it (the §4 table implies this). + +## Integration timing + +Wire it after `sched/` lands — the scheduler is what calls `start()` / `pause()` / +`resume()` / `cancel()` and drives `segment_budget().set_task_order()`. Order: +`sched/` (against these headers) → `download.add` → `start()` glue → the callback→wire +projection. `sched/` does not need the `Engine` bodies, only the signatures in this doc, +so stage 8 and `sched/` proceed in parallel. From ea8fd7ca8fcdb70ec33c54929984d76e2e583a70 Mon Sep 17 00:00:00 2001 From: sami Date: Thu, 10 Sep 2026 15:51:16 +0400 Subject: [PATCH 7/7] daemon: fold CORE's confirms into the engine-API review CORE resolved all four (lane/core@3da4cd6): vdm::TaskId is hashable and DAEMON never constructs one; DAEMON mkdir -p's save_path's parent (engine -> Error::path_rejected if missing); sha512 added as the 4th Checksum::Algo so no -32602 at the RPC edge; on_state(cancelled) then on_finished(Err{Error::canceled}) -- note the one-L spelling in the error taxonomy. Also notes rate/token_bucket + Engine::rate_limiter() for the limiter.set wiring. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Upd9WhG9oppieig5nRDLig --- daemon/docs/engine-api-review.md | 38 ++++++++++++++++++-------------- 1 file changed, 21 insertions(+), 17 deletions(-) diff --git a/daemon/docs/engine-api-review.md b/daemon/docs/engine-api-review.md index b907e91..bddd2fa 100644 --- a/daemon/docs/engine-api-review.md +++ b/daemon/docs/engine-api-review.md @@ -62,24 +62,28 @@ and it is already `DownloadSpec.mirrors` + the segmenter's 3-failure requeue, no segments to the new URL from their current offsets; if they don't match, surface it (`on_decision_needed` or a `refresh_url` error) rather than silently restarting. -## To confirm — none block sign-off +## Confirmed by CORE (`lane/core@3da4cd6`) -1. **`vdm::TaskId` ↔ wire UUID.** `sched/` keeps a `vdm::TaskId → wire taskId` map, keyed - off `handle.id()` at `start()`. Confirm `vdm::TaskId` is cheap to copy and hashable - (`std::unordered_map` key), and that DAEMON is never expected to *construct* one — it - only ever receives them from `start()` / callbacks and passes them back to - `set_task_order()`. -2. **Parent directory creation.** The engine "never canonicalises or root-checks" - `save_path` — does it `mkdir -p` the parent (the category folder may not exist yet), or - must DAEMON ensure the directory exists before `start()`? DAEMON assumes the latter - (it owns category/path logic) unless you say otherwise. -3. **`Checksum::Algo` has no `sha512`**, but `Checksum.schema.json` accepts it (PROTO F3). - DAEMON will reject `algorithm: "sha512"` at the RPC edge with `-32602` before `start()` - unless CORE would rather carry a fourth enum value. Either is fine; say which. -4. **`on_finished(Err{cancelled})`** — DAEMON keys on `ErrorInfo.code == cancelled` to - write a `cancelled` history row rather than surfacing it as a user-facing failure. - Confirm `cancelled` is the code and that `on_state(_, cancelled, nullopt)` always - precedes it (the §4 table implies this). +1. **`vdm::TaskId`** — cheap-copy and `std::hash`-able; DAEMON never constructs one, only + receives it from `start()` / callbacks and passes it back to `set_task_order()`. + `sched/` keeps the `vdm::TaskId → wire taskId` map keyed off `handle.id()`. +2. **Parent directory** — DAEMON `mkdir -p`s `save_path`'s parent before `start()`; the + engine opens the file and fails the task with `Error::path_rejected` if it is missing. + Now explicit on `DownloadSpec`'s doc comment. +3. **`sha512`** — added as the fourth `Checksum::Algo`, matching the wire set. No `-32602` + at the RPC edge; DAEMON passes it straight through. +4. **Cancel ordering** — `on_state(_, cancelled, nullopt)` then `on_finished`, always in + that order. Note the taxonomy value is spelled `canceled` (one L): the finish is + `on_finished(Err{Error::canceled})`, `error.code == canceled`. `sched/` keys on that + to write a `cancelled` history row rather than a user-facing failure. (The wire + `TaskState` / `EngineState` spelling stays `cancelled`; only `vdm::Error` is one-L.) + +## Related, landed the same pass + +`rate/token_bucket` — the global → queue → task speed-limiter hierarchy (`docs/04` §6), +reached via `Engine::rate_limiter()`. DAEMON's `limiter.set {globalBps, enabled}` maps to +`rate_limiter().set_global_limit(...)`; `limiter.get` reads it back. Wire this alongside +the `download.add` → `start()` glue. ## Integration timing