Skip to content

Changelog

All notable changes to tobor are recorded here.

Format follows Keep a Changelog. tobor follows Semantic Versioning, as documented in the project ../README.md.

Discipline. Every merged PR adds at least one entry under [Unreleased]. On release, [Unreleased] is renamed to the release version with the release date, and a fresh [Unreleased] section is created for the next cycle. This is one of the three sources of truth for tobor's version, alongside ../CMakeLists.txt (project(... VERSION ...)) and tobor --version; all three must agree at release.

Section headings, in order (only used sections are shown per release):

  • Added - new features and capabilities.
  • Changed - changes to existing behavior.
  • Deprecated - features scheduled for removal.
  • Removed - features that were removed.
  • Fixed - bug fixes.
  • Security - vulnerability fixes.

Task references. Each entry references the BACKLOG.md task ID (e.g. TV-16) that produced it. The Monday.com item ID lives on the PR and in the commit trailer, not here.


[Unreleased]

[0.5.3] - 2026-09-30

Security

  • Credential folder permissions no longer applied to folders tobor did not create (TV-XX). save_credential set the containing folder to 0700 unconditionally. With TOBOR_CREDENTIAL_PATH pointing into an existing shared folder (an operator's home directory, or /tmp on a robot running as root), this silently locked that folder to owner-only and broke unrelated programs on the machine. fs::create_directories reports whether it created the folder, so the permission change now applies only in that case. The credential file itself is still written 0600 either way. Reproduced before the fix and confirmed after. Note the behaviour change for the documented install: /var/lib/tobor is created by the operator, so tobor no longer re-permissions it. The install steps in ../README.md now set 0700 explicitly, and the directory-mode guarantee there is worded to match.
  • Certificate verification options are checked, not assumed (TV-XX). CURLOPT_SSL_VERIFYPEER and CURLOPT_SSL_VERIFYHOST were set without inspecting the CURLcode return. Had either failed to apply, the request would have proceeded with verification disabled and the robot's API key would have gone out over an unverified connection with nothing reported. Both are now checked and the request fails if either does not take; CURLOPT_CAINFO is checked the same way.
  • Auth headers are no longer dropped when the header list cannot grow (TV-XX). curl_slist_append returns nullptr on allocation failure, and the result was assigned straight back to the list, discarding every header added so far. An authenticated call could therefore be sent with no Authorization header and fail as a confusing 401. The header list is now owned privately by CurlSlist, whose append checks before replacing.

Fixed

  • A device serial that is not valid UTF-8 no longer aborts the process (TV-XX). nlohmann's dump() is strict UTF-8 and throws, and every field in the register body is unvalidated raw bytes: the device-tree serial and model, /etc/machine-id, /etc/os-release, and the TOBOR_* environment variables. Nothing caught it, so one stray high byte in a robot's device tree ended in SIGABRT and a core dump instead of the operator-facing error every other path produces. Reproduced at exit 134. build_register_body now returns nullopt and register reports it, pointing at TOBOR_DEVICE_SERIAL. Substituting replacement characters was rejected: a serial is an identifier, and altering it silently would have the cloud record a serial the robot does not have. main also gained a catch-all around command dispatch, so nothing else can reach std::terminate either.
  • One robot, one serial, however it was read (TV-XX). device_info.cpp and credential_store.cpp each had their own read-and-trim helper with different rules: one stripped only trailing whitespace plus NULs, the other both ends but not NULs. A device-tree serial of " ABC " therefore became " ABC" from the file but "ABC" via TOBOR_DEVICE_SERIAL, so one robot could present two different identifiers depending on where the value came from. Both now use trim_bytes and read_file_trimmed in util.hpp.
  • Credential temp files use mkstemp (TV-XX). <path>.<pid>.tmp opened O_EXCL collides with a stale temp file left behind by a process killed between the open and the rename, and PIDs restart low after a reboot, so a later run could fail with nothing to explain why. The error messages in that function also named the final path rather than the temp path and omitted errno; both are fixed, so a failure now says which file and why.
  • All certificate failures are treated as terminal (TV-XX). Only CURLE_PEER_FAILED_VERIFICATION and CURLE_SSL_CACERT_BADFILE set tls_verify_failed. The rest (CURLE_SSL_ISSUER_ERROR, CURLE_SSL_INVALIDCERTSTATUS, CURLE_SSL_PINNEDPUBKEYNOTMATCH, CURLE_USE_SSL_FAILED) fell through as generic transport failures, so register reported "couldn't reach the cloud" and burned every retry attempt on a certificate problem that cannot resolve itself. This contradicted the documented contract that a TLS failure is never retried. All six codes verified present in libcurl 7.68 (the release container's version).
  • Retry backoff is capped at 60s per wait (TV-XX). The delay doubled without limit, so at the maximum permitted settings (TOBOR_REGISTER_MAX_ATTEMPTS=10, TOBOR_REGISTER_RETRY_DELAY=60) the client could sit silent for over eight hours. The guard on the doubling also prevents an int overflow should the permitted range ever widen.
  • The response status is read before the result is marked successful (TV-XX). A failed curl_easy_getinfo left status at 0 while ok was set to true, which classify then reported as UnexpectedStatus with a meaningless code.
  • join_url strips every trailing slash (TV-XX). A TOBOR_CLOUD_URL ending in // produced a double slash in the request path. Only reachable from a misconfigured environment variable.
  • The response buffer is attached before the request runs (TV-XX). CURLOPT_WRITEFUNCTION and CURLOPT_WRITEDATA were set unchecked; had either failed, libcurl would have written the response to stdout instead of into the result body.

Added

  • Smoke test suite (test/smoke_test.sh) (TV-XX). 19 checks covering the full register / check / unregister flow against mock-cloud/server.py, plus credential file and folder permissions (including that an operator-created folder is left untouched), a device serial that is not valid UTF-8, re-registration rejection, and cloud-unreachable handling. Added before any code change in this cycle and proven to pass on the unmodified code first, so every later failure was attributable to the change under test.
  • Strict compiler warnings and a .clang-tidy config (TV-XX). -Wshadow, -Wconversion, -Wsign-conversion, -Wcast-qual and -Wold-style-cast join -Wall -Wextra -Wpedantic; the code is clean under all of them. -Wswitch-enum was tried and dropped, since CURLcode has around 100 enumerators and the TLS classifier deliberately handles six, which produced 95 warnings from a single switch. clang-tidy runs the CERT, C++ Core Guidelines and HICPP checks that overlap MISRA most; the baseline is 33 findings, none of them defects. Until this, D-0031's adoption of MISRA had nothing enforcing it. See D-0032.
  • .clang-format (TV-XX). The existing house style captured as a config (4-space indent, indented namespaces, attached braces, 120 columns), applied across all sources in one commit recorded in .git-blame-ignore-revs.

Changed

  • Headers use #ifndef include guards instead of #pragma once (TV-XX). Reverses D-0004; see D-0031 for the reasoning.
  • The local cross-build image matches CI (TV-XX). Dockerfile was ubuntu:22.04 while both workflows pin container: ubuntu:20.04 to hold the glibc floor at 2.31 for the Orin Nano fleet (D-0030). The local path the README documents as matching the robot runtime was therefore producing a binary that would not load on a 20.04 robot, the exact failure D-0030 exists to prevent.
  • CURLOPT_FOLLOWLOCATION is documented as deliberately unset (TV-XX). libcurl replays request headers to a redirect target, so following a redirect would send the robot's Bearer token to whatever host the cloud points at. Not following redirects was already the behaviour; it is now written down so nobody enables it later without stripping the auth header first.
  • CURLOPT_NOSIGNAL is set on every request (TV-XX). libcurl may otherwise use signals to implement timeouts, which is unsafe in a multi-threaded process. Set ahead of the planned serve command rather than after it.
  • The libcurl RAII wrappers own their resources privately (TV-XX). CurlHandle, CurlSlist and HttpGlobal were structs with public members, so the handle each existed to own could be overwritten from outside and leaked or double-freed. All three are now classes exposing only what callers need, with copy and move operations explicitly deleted.
  • Environment integers are parsed with std::from_chars (TV-XX). std::stoi reports failure by throwing, which required a try/catch in a header other modules will reuse; from_chars returns an error code instead, with no exception, allocation or locale dependency. The bounds moved into an IntRange struct so they cannot be silently swapped at a call site.
  • Deliberately ignored return values are marked (void) (TV-XX), so a reader can tell a considered decision from a forgotten check. Applied to cleanup calls in credential_store.cpp and the libcurl options where failure is not actionable.
  • Assorted correctness-preserving cleanups (TV-XX). Named permission constants in place of the octal 0600 literal; std::array for the command table; a bounds-checked offset in place of pointer arithmetic in the credential write loop; an explicit write order in save_credential in place of a side effect on the right of &&; a variable renamed where it shadowed a function of the same name; the response size limit computed in std::size_t.

[0.5.2] - 2026-09-25

Fixed

  • Ubuntu 20.04 (TV-XX). Added binary support for 20.04 version for the quadrupeds.

Changed

  • Docs aligned to the delivered cloud contract (TV-XX). CLOUD_BACKEND.md and README.md updated to the real /api/robots/... paths, register 400 (not 403) and already-registered handling, osRelease as PRETTY_NAME, and verify/unregister as delivered endpoints rather than stand-ins; README version bumped to 0.5.1. Docs only.

[0.5.1] - 2026-09-22

Fixed

  • Heartbeat and unregister use the real /api/robots/... paths (TV-XX). The heartbeat and unregister transport paths were the short-form stand-ins (/heartbeat, /unregister); they now point at the delivered cloud paths (/api/robots/heartbeat, /api/robots/unregister). Against the live cloud, unregister's revoke was hitting the wrong path before this. verify, register, and health already matched. See CLOUD_BACKEND.md.
  • register names the already-registered case (TV-XX). The real cloud returns 400 for an already-registered robot as well as for a bad, expired, or used key; the 400 message now mentions that case instead of blaming the key alone.
  • unregister message is honest when the cloud does not have the robot (TV-XX). The real cloud returns 400 "robot not found"; the message no longer claims the credential "may still be accepted," and points the operator to revoke cloud-side only if the cloud still has the robot.
  • Stray commented-out declaration removed from commands.hpp (TV-XX). Leftover from an earlier newline fix; no behavior change.

Changed

  • osRelease reports only PRETTY_NAME (TV-XX). register sent the entire /etc/os-release file as osRelease; it now sends just the PRETTY_NAME value (for example Ubuntu 22.04.5 LTS), matching the contract's example and keeping the device record readable.
  • Mock cloud aligned to the delivered contract (TV-XX). mock-cloud/server.py now serves the real /api/robots/... paths, returns 400 (not 403) for a bad register key, uses the delivered response shapes (status/success/message, robotDetails + data, status: "connected"), returns numeric status: 200 in health, distinguishes 401/400/403 on unregister, and echoes X-Robot-ID. Dev-only.

[0.5.0] - 2026-09-15

Changed

  • Register wire format moved behind the transport seam (TV-XX). register's request building and response parsing moved out of cmd_register into the transport: a typed RegisterRequest goes in and a typed RegisterResult comes out, with build_register_body and parse_register_response owning the JSON field names inside http_client.cpp. commands.cpp no longer includes nlohmann/json or names a single wire field, so a cloud-side rename of the register request or response touches one file. Behavior on the wire is unchanged. Amends the "payload construction stays in the command layer" note of D-0008 and D-0011. See DECISIONS.md D-0026.
  • check verifies the credential against the cloud (TV-XX). check now runs GET /health and then presents the stored credential to GET /api/robots/verify, reporting the credential verified (naming the robot ID), rejected (may have been revoked, re-register), or a transport failure, instead of reporting the local file's presence only. This restores the cloud-side credential validation that D-0022 had reduced to health-plus-local, now that the robot defines the verify endpoint it needs. The check help text is updated to match. See DECISIONS.md D-0028 (amends D-0016, D-0022).
  • Register reconciled to the real cloud API (TV-XX). register now posts to POST /api/robots/register with a body of registerKey plus device { serialNo, robotModel, firmwareVersion, ... }, and reads the reply's robotDetails { id, apiKey }, replacing the old /register path, the token plus device { serial, model, machine_id, os_release } body, and the bare credential response. serialNo comes from the hardware serial; robotModel and firmwareVersion are operator-supplied (TOBOR_ROBOT_MODEL, TOBOR_FIRMWARE_VERSION), and any empty optional device field is omitted rather than sent as "" so a strict backend does not reject it. machineId and osRelease ride along as metadata. Matched to the cloud team's delivered contract; pre-1.0 this is a MINOR-level contract break. See DECISIONS.md D-0022, and CLOUD_BACKEND.md.
  • Credential store keeps the cloud robot ID (TV-XX). The store persists two values now: the API key in credential (unchanged file format) and the cloud-assigned robot ID in a sibling robot-id file, both 0600 inside the 0700 directory and written atomically, ID first and key last so a crash mid-write reads as "not registered" rather than corrupt. save_credential(robot_id, api_key) and load_credential handle both; a credential present without a readable ID is reported as an incomplete pair, and remove_credential removes both files. The durable identity is now the cloud's robot ID; the hardware serial stays the register-time bootstrap only. See DECISIONS.md D-0021 (amends D-0010, D-0011).
  • Status codes match the real cloud (TV-XX). classify treats 403 as an auth rejection alongside 401, and register maps 400 to a "registration key not found, expired, or already used" message. The register retry split is unchanged: a completed 4xx is terminal, 5xx and transport failures retry. See DECISIONS.md D-0022.
  • mock cloud matches the real register contract (TV-XX). The mock serves POST /api/robots/register with the registerKey / device { serialNo, ... } body and a robotDetails { id, apiKey, status } reply, and GET /health returns JSON (success, status). /heartbeat and /unregister remain as in-memory stand-ins for the future validate and robot-revoke endpoints.
  • Shared cloud-URL resolution (TV-XX). The env_or("TOBOR_CLOUD_URL", ...) lookup, now used by register, check, and unregister, is factored into a single cloud_url() helper in commands.cpp (the third caller, by the rule of three), matching the existing env_or / credential_path consolidation.
  • run renamed to check (TV-XX). The connectivity command is now check; tobor run reports unknown command. A CLI-contract break, which pre-1.0 is a MINOR change, reflected in the README and CLOUD_BACKEND.md. The name serve is reserved for the future always-on online signal. See DECISIONS.md D-0016.
  • mock-cloud credential format (TV-XX). The issued API key is now tobor-key-<serial> (earlier tobor-cred-<serial>, originally the fixed tobor-cred-CHANGED-2026), so different robots get distinct credentials the mock can validate. Dev-only.

Added

  • First-time credential verification (TV-XX). A new verify_credential transport calls the robot-defined GET /api/robots/verify with the stored credential as a Bearer token and returns 200 accepted or 401/403 rejected (the body is not inspected). The path is a swappable placeholder isolated behind the transport, so it re-points to the cloud's real validate path with a one-line change. It is deliberately its own call, kept separate from the dormant send_heartbeat (whose home is the future serve loop). See DECISIONS.md D-0028 and CLOUD_BACKEND.md.
  • register verifies the new credential before reporting success (TV-XX). After saving the credential, register verifies it with the cloud and reports success only once it is confirmed usable. Pragmatic policy: an outright 401/403 rejection fails the command; a transient reachability or TLS failure leaves the registration in place and points the operator at check; the saved credential is kept in every case. See DECISIONS.md D-0028.
  • Typed RobotAuth, single auth-header spot, and the robot ID on the wire (TV-XX). Authenticated transport calls take a typed RobotAuth carrying both the robot ID and the API key, and the request's auth headers are built in one auth_headers function. It now sends Authorization: Bearer <apiKey> and X-Robot-ID: <robot ID>, so both stored values reach the cloud (the key authenticates and already identifies the credential; the robot ID is a cross-check). Adding, removing, or renaming a header (for example moving the key to X-Robot-API-Key later) is a one-line edit there. verify_credential and send_revoke use it. See DECISIONS.md D-0027.
  • mock cloud verifies credentials on GET /api/robots/verify (TV-XX). The mock serves the robot-defined verify endpoint, returning 200 for a Bearer credential it issued and 401 otherwise, and the repeated Bearer parsing in /heartbeat and /unregister is factored into one helper. In-memory only, dev-only.
  • unregister decommissions a robot (TV-XX). New tobor unregister removes the stored credential so a robot can be reset, resold, or decommissioned. It asks the cloud to revoke the credential first (POST /unregister with the credential as a Bearer token), then deletes the local files; the delete happens even when the cloud is unreachable or does not recognize the credential, so an offline decommission still works, and the command says when a revoke could not be confirmed. With no credential stored it is a friendly no-op. It shows the exact path and removes the files only on a typed yes; --force skips the prompt for automation. Only the credential and robot-id files are removed (/var/lib/tobor stays), so re-registering needs no root. Revoke-before-delete is also crash-safe. See DECISIONS.md D-0019.
  • remove_credential and credential_path in the store (TV-XX). remove_credential deletes the credential and returns true when the file is gone (deleted or already absent); credential_path exposes the resolved path so a command can name the file without duplicating the default. save_credential and load_credential now resolve the path through credential_path too. See DECISIONS.md D-0019.
  • send_revoke transport and mock /unregister (TV-XX). A new send_revoke posts to /unregister with the credential as a Bearer token, mirroring send_heartbeat. The mock cloud implements /unregister: it forgets the credential it issued, so a later /heartbeat with that credential returns 401. In-memory only, like the rest of the mock. See CLOUD_BACKEND.md.
  • load_credential says why a read failed (TV-XX). It now returns a status (Loaded, Missing, Unreadable, or Empty) instead of just the text or nothing, so check can tell "not registered" apart from a permissions problem, a directory path, or an empty file, and report each one plainly. See DECISIONS.md D-0018.
  • Shared HTTP result classification (TV-XX). A new HttpOutcome enum and a classify() function interpret an HTTP result in one place (TLS failure, unreachable, 200, auth rejection, other), so register and check share the logic instead of each repeating it. See DECISIONS.md D-0018.
  • Command table for the CLI (TV-XX). Commands are declared in one table (name, help line, handler), so --help and dispatch stay in sync and adding a command is one line. See DECISIONS.md D-0017.
  • mock-cloud validates heartbeats (TV-XX). /register records the credential it issues, and /heartbeat checks the Bearer credential, returning 200 for a known one and 401 otherwise, so both paths can be tested. In-memory only, so restarting the mock forgets issued credentials.
  • GitHub Actions CI for the aarch64 target (.github/workflows/aarch64-build.yml) (TV-22). Builds the Release aarch64 binary on a native ubuntu-22.04-arm runner on every merge to main (plus a manual trigger), ending with a --version smoke test. Runs only on merge to keep cost minimal, and uses the 22.04 arm image so the toolchain matches the robot (D-0001, D-0003). Tooling only, no change to the shipped binary. Rationale in DECISIONS.md D-0014.
  • Release workflow publishes the aarch64 binary (.github/workflows/release.yml) (TV-23). Pushing a version tag (v*.*.*) builds the Release aarch64 binary on ubuntu-22.04-arm, checks the tag matches the binary's version, and publishes a GitHub Release with a checksummed tobor-<version>-aarch64.tar.gz (the binary named tobor inside). Also: the build workflow now skips doc-only merges (paths-ignore) and both workflows log the toolchain versions and build verbosely. Tooling only. Rationale in DECISIONS.md D-0015.
  • Dev tooling: the mock cloud takes a --host flag (default 127.0.0.1) so it can bind 0.0.0.0 and answer other machines, which lets you smoke-test a release binary on the robot against a mock running on your laptop.
  • Docs: add docs/CLOUD_BACKEND.md, the cloud-side integration spec for Mode A (endpoints, the register and online-signal flow, and open questions). Docs only.

Fixed

  • Honest check help and register-path comment (TV-XX). The check command's one-line help no longer claims it validates the credential (it checks reachability and local registration), and the register_robot transport comment names the real /api/robots/register path instead of the old /register. Text only, no behavior change.
  • Credential write is durable across power loss (TV-XX). write_file_atomic now writes the .tmp sibling through raw open/write, fsyncs the file descriptor, renames it into place, then fsyncs the parent directory, so a completed register survives a power cut and not only a clean crash. Previously the rename was atomic but the file's bytes and the new directory entry could still be sitting in the kernel page cache when power was lost, leaving a zero-length or stale credential even though the rename "succeeded". The ID-first / key-last write order is unchanged, so an interrupted write still reads as "not registered" (Missing) rather than corrupt. See DECISIONS.md D-0024 (amends D-0010).
  • POST body is copied into libcurl (TV-XX). The transport sets CURLOPT_COPYPOSTFIELDS (with CURLOPT_POSTFIELDSIZE set first, so the exact byte count is copied) instead of CURLOPT_POSTFIELDS, so libcurl owns a copy of the request body rather than borrowing the caller's pointer. No behavior change today, since the body already outlived the request; this removes a latent dangling-pointer trap for any future refactor that passes a temporary body. See DECISIONS.md D-0025.
  • libcurl global state is initialized once at startup (TV-XX). main now calls curl_global_init once before any request and curl_global_cleanup at exit, wrapped as http_global_init / http_global_cleanup so main stays free of curl (D-0008), with cleanup tied to an RAII guard so it runs on every exit path. This replaces relying on curl_easy_init to set up libcurl's global state lazily, which is not thread-safe. Single-threaded behavior is unchanged; this disarms a race before the planned threaded serve. --help and --version return before the guard is created, so they still need no network setup. See DECISIONS.md D-0025.

Security

  • Response body is capped at 1 MiB (TV-XX). The transport write callback now aborts the transfer once an accumulated response exceeds 1 MiB, returning a short count so libcurl fails the request, which the existing error handling reports as a failed call. This stops a broken or hostile endpoint from growing the response buffer until the process runs out of memory. The register and health replies are far under the cap. See DECISIONS.md D-0025 and CLOUD_BACKEND.md.
  • Credential temp file is created owner-only (TV-XX). The .tmp sibling is created with open(O_CREAT | O_EXCL, 0600), so it is 0600 from its first instant instead of being created at the process umask and tightened to 0600 afterward. This removes the brief window (recorded as an accepted residual in D-0010) where the plaintext API key sat on disk more readable than intended. See DECISIONS.md D-0024.

[0.4.0] - 2026-09-01

Added

  • HTTPS/TLS for the Mode A transport (../src/http_client.cpp, ../include/http_client.hpp) (TV-21). The transport now uses TLS whenever the cloud URL is https:// (plain http:// is unchanged), driven entirely by the URL scheme. Certificate verification is always on and set explicitly (CURLOPT_SSL_VERIFYPEER and CURLOPT_SSL_VERIFYHOST); there is no switch to disable it. A new optional TOBOR_CA_BUNDLE points libcurl at a custom trust anchor (CURLOPT_CAINFO) for a self-signed dev cert or a private CA; unset, tobor uses the system CA bundle, so the real cloud needs no configuration (the robot needs ca-certificates installed). tobor presents no client certificate: this is one-way TLS, and mutual TLS remains a Mode B concern. Rationale in DECISIONS.md D-0013.
  • TLS trust failures are terminal and reported honestly (../src/commands.cpp, ../include/http_client.hpp) (TV-21). The transport surfaces a TLS trust failure as a distinct outcome (HttpResult.tls_verify_failed, set from CURLE_PEER_FAILED_VERIFICATION when the cloud's certificate is not trusted, or CURLE_SSL_CACERT_BADFILE when the TOBOR_CA_BUNDLE file cannot be read). is_retryable treats it as terminal, so register no longer retries a bad certificate or a mistyped CA path four times, and both register and run report that the cloud's TLS certificate could not be verified instead of the misleading "couldn't reach the cloud". The transport reports the fact; the commands decide and phrase it, keeping the D-0008 split.
  • mock-cloud can serve HTTPS (../mock-cloud/server.py) (TV-21). The mock takes --tls, --port, --cert, and --key flags and wraps its socket with a self-signed certificate for local TLS testing; plain HTTP on 8080 is unchanged when the flags are omitted. Responses now send a Content-Length header so a TLS client reads a complete body instead of failing on the connection close. The dev cert and key are generated from a documented openssl command and are git-ignored.

[0.3.0] - 2026-09-01

Added

  • Retry with backoff on register (../src/commands.cpp) (TV-20). cmd_register now wraps the one-shot register_robot in a small retry loop that retries only transient failures - a transport failure (could not connect or timed out) or a 5xx - and stops at once on any settled answer (200, 401, other 4xx, or a credential-save failure). The retryable/terminal split is a private is_retryable(HttpResult) helper. Default policy: 4 attempts (first try plus 3 retries) with delays of 1s, 2s, 4s (base 1s, doubling), about 7 seconds worst case, printing a visible retrying (n/N) in Xs... line before each wait. The transport stays one-shot and protocol-neutral; the loop and all its user-facing messages live in the command layer, preserving the D-0008 split. Retry is safe because the cloud keys the device on its hardware serial (D-0011), so a repeat updates the same record. Rationale in DECISIONS.md D-0012.
  • env_int_or helper and two retry env knobs (../include/util.hpp) (TV-20). New header-only env_int_or(name, fallback, min, max) reads an integer from the environment, falling back to the default when the variable is unset, non-numeric, or out of range. cmd_register uses it to expose TOBOR_REGISTER_MAX_ATTEMPTS (1 to 10, default 4) and TOBOR_REGISTER_RETRY_DELAY (0 to 60 seconds, default 1), following the D-0005 runtime-config pattern. The bounded maximum also keeps the doubling delay from overflowing an int.

[0.2.0] - 2026-09-01

Breaking: the /register request body changed from a bare token to a JSON object. Pre-1.0, breaking changes bump the minor version (see ../README.md "Versioning").

Added

  • Device identifiers gathered for registration (../src/device_info.cpp, ../include/device_info.hpp) (TV-19). New collect_device_info() returns a DeviceInfo with the robot's hardware serial (/proc/device-tree/serial-number), machine_id (/etc/machine-id), raw os_release (/etc/os-release), and board model (/proc/device-tree/model). Each is read defensively - a missing file yields an empty string, not an error - so one binary runs unchanged on the aarch64 robot (device-tree present) and the x86 dev box (device-tree absent). The serial is overridable via TOBOR_DEVICE_SERIAL for dev machines that have no device-tree. Anchor choice and the send-raw-os_release rationale in DECISIONS.md D-0011.
  • mock-cloud /register accepts the JSON payload (TV-19). The mock server parses the JSON body, requires both token and serial (missing either produces a 400), validates the token (401 on mismatch), records each device by serial in an in-memory registry, and returns the credential on success. Stands in for the cloud's device database.

Changed

  • /register request body is now JSON (TV-19), a breaking change to the request contract, coordinated with the cloud team. The body was the raw token string; it is now {"token": "...", "device": {"serial": ..., "machine_id": ..., "os_release": ..., "model": ...}}, with token and serial required. The response (a JSON credential) is unchanged. cmd_register builds the payload with nlohmann/json and dump()s it before sending. See DECISIONS.md D-0011.
  • register_robot(base_url, body) (TV-19). The transport's second parameter was token; it is now a neutral body. Payload construction stays in the command layer (cmd_register); the transport just posts the bytes it is given, keeping it protocol-neutral (D-0008).

[0.1.4] - 2026-08-31

Added

  • Credential storage module (../src/credential_store.cpp, ../include/credential_store.hpp) (TV-19). New save_credential(credential) persists the credential from a successful /register to disk, mirroring the transport seam: the module owns all file mechanics, and cmd_register just calls it and checks a bool. The write is atomic: the credential is written to a .tmp sibling which is then renamed into place, so a crash or power loss can never leave a half-written or lost credential, making re-registration safe. The file is locked to 0600 and its directory to 0700 (owner-only), sized to the same uid the robot's containers run as, so tobor and the on-robot container can read it while other users cannot. The on-disk path defaults to /var/lib/tobor/credential and is overridable via the TOBOR_CREDENTIAL_PATH environment variable, following the D-0005 runtime-config pattern. Storage rationale (location, permissions, plaintext-vs-encryption, container access) in DECISIONS.md D-0010. (The power-loss durability claim here is fully made good in D-0024, which adds the fsync steps.)
  • register saves the credential and no longer prints it (TV-19). On a 200, cmd_register now calls save_credential and reports Success! only once the credential is safely on disk; on a storage failure it reports the reason in plain language and exits non-zero. The temporary line that echoed the credential to stdout was removed, so the secret never reaches the terminal or the systemd journal.

Changed

  • Shared env_or helper (TV-19). The duplicated getenv-or-default logic (a third copy appeared once the credential path was added) was factored into a single env_or(name, fallback) in ../src/commands.cpp. cmd_register and cmd_run use it for TOBOR_CLOUD_URL; the credential path resolves the same way inside credential_store.cpp, to be unified when the config file lands.

[0.1.3] - 2026-08-31

Added

  • register command (TV-19). cmd_register obtains the per-robot token from a CLI argument (tobor register "<token>") or, if none is given, an interactive prompt ("Paste your registration token and press Enter:"); an empty token is a usage error (exit 2). It then sends the token to the cloud and reports the outcome in plain language: Success! Your robot is registered. on 200, that token wasn't accepted... on 401, and a can't-reach-the-cloud message on a transport failure. Token-input rationale in DECISIONS.md D-0007.
  • JSON parse of the /register response (TV-19). On a 200, cmd_register parses the cloud's JSON body with the vendored nlohmann/json and extracts the credential field, replacing the temporary raw-body print. A malformed body, a missing credential key, or an empty credential is reported in plain language and exits non-zero; the parse is wrapped in try/catch, since nlohmann/json signals these by throwing. Parser choice and vendoring rationale in DECISIONS.md D-0009.
  • HTTP POST transport (TV-19). register_robot(base_url, token) sends the token to /register via a libcurl POST (CURLOPT_POSTFIELDS) and returns the result.
  • mock-cloud /register validation (TV-19). The mock server now checks the received token against a known-good value: a match returns 200 + a JSON credential, anything else returns 401 + an error, so both the success and rejection paths can be developed and tested against it.

Changed

  • Transport returns a result; commands interpret it (TV-19). Introduced an HttpResult struct (ok / status / body). check_cloud_health and register_robot now perform the request and return the raw result only; cmd_run and cmd_register do the judging (the 200 check) and all user-facing messages. Moves policy and presentation out of the transport layer. See DECISIONS.md D-0008.
  • Shared HTTP request helper (TV-19). The duplicated curl setup across the two transport functions is factored into one private perform_request(url, post_body) - GET when post_body is null, POST otherwise - leaving check_cloud_health and register_robot as thin wrappers. libcurl stays fully hidden inside http_client.cpp.

[0.1.2] - 2026-08-28

Added

  • Commands module (../src/commands.cpp, ../include/commands.hpp) (TV-17). cmd_register and cmd_run extracted from main.cpp into their own module in the tobor namespace, leaving main.cpp as pure CLI dispatch. First use of the header / #pragma once convention (D-0004).
  • HTTP transport module (../src/http_client.cpp, ../include/http_client.hpp) (TV-17). check_cloud_health(base_url) performs a GET <base_url>/health via libcurl, collecting the reply through a write callback. Hardened with a 3-second connect / 5-second total timeout, an HTTP-200 status check (a non-200 reply is a failure even when libcurl's transport reports success), and an RAII handle wrapper (CurlHandle) that always frees the curl handle on every exit path and forbids copying. Health-success policy in DECISIONS.md D-0006.
  • Real run behavior (TV-17). cmd_run now performs a live cloud connectivity check instead of printing a stub, exiting non-zero when the cloud is unreachable, times out, or answers non-200.
  • Runtime cloud URL configuration (TV-17). The cloud base URL is read from the TOBOR_CLOUD_URL environment variable, with a single named dev default (http://127.0.0.1:8080, the mock cloud); endpoint paths (/health) are named constants. No cloud address is hardcoded in the transport. See DECISIONS.md D-0005.

Changed

  • main.cpp tidied (TV-17). File-private helpers grouped in an anonymous namespace (replacing static), and usage-error text factored into usage_error / unknown_command_error so the wording lives in exactly one place.
  • D-0004 recorded (TV-17): headers use #pragma once for include guarding, given tobor's fixed gcc/clang target set. See DECISIONS.md D-0004.

[0.1.1] - 2026-08-27

Added

  • Build system in ../CMakeLists.txt (TV-16). Replaces CLion's default hello-world scaffold. Baselines CMake 3.16 as the build-tool floor so tobor builds on Ubuntu 20.04 or newer. Declares project(tobor VERSION 0.1.1 LANGUAGES CXX), enforces C++17 strictly with no compiler extensions, adds ../include as a target include directory, turns warnings into errors in Release builds, and carries the D-0001 -lstdc++fs conditional link guard for future gcc 9.4 support.
  • docs/CHANGELOG.md itself (TV-16). Establishes the per-PR change-recording discipline that keeps this file honest.
  • CLI entry point in ../src/main.cpp (TV-17). First real source file. Implements -h/--help and -v/ --version (both print to stdout, exit 0); subcommand dispatch routing register and run to stub handlers; and usage-error handling where no command or an unrecognized command prints a one-line message to stderr and exits 2. Replaces the CLion hello-world scaffold in main.cpp.
  • Compile-time version injection in ../CMakeLists.txt (TV-17). target_compile_definitions passes PROJECT_VERSION to the code as the TOBOR_VERSION preprocessor definition, so tobor --version reads the version from the single project(... VERSION ...) source with no duplicated literal.
  • libcurl dependency wired into the build (TV-17). find_package(CURL REQUIRED) and target_link_libraries(tobor PRIVATE CURL::libcurl), linking the system libcurl (OpenSSL flavour, 7.81.0) that matches the robot runtime. Rationale in DECISIONS.md D-0003.
  • mock-cloud/ test server (TV-17). Python standard-library HTTP server standing in for the Mode A cloud on the dev laptop. Plain-HTTP endpoints GET /health, POST /register (stub JSON credential), and POST /heartbeat. Not part of the shipped binary. HTTPS deferred to a later step.

Changed

  • D-0002 recorded (TV-17): the build-tool baseline (CMake 3.16, from the Ubuntu 20.04 retrofit posture) is now formally distinguished from the Ubuntu 22.04 runtime target, resolving the discrepancy flagged against D-0001. ../CMakeLists.txt stays at cmake_minimum_required(VERSION 3.16). See DECISIONS.md D-0002.

Prior work (pre-changelog, not itemized)

TV-1 documentation and workflow tasks (TV-7 through TV-11, and any others closed before this file existed) predate this changelog. Their outputs are visible in the git history, ../README.md, DECISIONS.md, docs/BACKLOG.md, and docs/GIT_WORKFLOW.md. Not backfilling entries here; starting fresh from TV-16 onward.


Release history

(No formal tagged releases yet. The version sections above track the rolling pre-alpha increments; each bump keeps ../CMakeLists.txt and tobor --version in step. The first tagged release will be cut from these once Mode A runs on a fielded robot.)