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
tobordid not create (TV-XX).save_credentialset the containing folder to0700unconditionally. WithTOBOR_CREDENTIAL_PATHpointing into an existing shared folder (an operator's home directory, or/tmpon a robot running as root), this silently locked that folder to owner-only and broke unrelated programs on the machine.fs::create_directoriesreports whether it created the folder, so the permission change now applies only in that case. The credential file itself is still written0600either way. Reproduced before the fix and confirmed after. Note the behaviour change for the documented install:/var/lib/toboris created by the operator, sotoborno longer re-permissions it. The install steps in../README.mdnow set0700explicitly, and the directory-mode guarantee there is worded to match. - Certificate verification options are checked, not assumed (TV-XX).
CURLOPT_SSL_VERIFYPEERandCURLOPT_SSL_VERIFYHOSTwere set without inspecting theCURLcodereturn. 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_CAINFOis checked the same way. - Auth headers are no longer dropped when the header list cannot grow (TV-XX).
curl_slist_appendreturnsnullptron 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 noAuthorizationheader and fail as a confusing401. The header list is now owned privately byCurlSlist, whoseappendchecks 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 theTOBOR_*environment variables. Nothing caught it, so one stray high byte in a robot's device tree ended inSIGABRTand a core dump instead of the operator-facing error every other path produces. Reproduced at exit 134.build_register_bodynow returnsnulloptandregisterreports it, pointing atTOBOR_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.mainalso gained a catch-all around command dispatch, so nothing else can reachstd::terminateeither. - One robot, one serial, however it was read (TV-XX).
device_info.cppandcredential_store.cppeach 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"viaTOBOR_DEVICE_SERIAL, so one robot could present two different identifiers depending on where the value came from. Both now usetrim_bytesandread_file_trimmedinutil.hpp. - Credential temp files use
mkstemp(TV-XX).<path>.<pid>.tmpopenedO_EXCLcollides 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 omittederrno; 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_VERIFICATIONandCURLE_SSL_CACERT_BADFILEsettls_verify_failed. The rest (CURLE_SSL_ISSUER_ERROR,CURLE_SSL_INVALIDCERTSTATUS,CURLE_SSL_PINNEDPUBKEYNOTMATCH,CURLE_USE_SSL_FAILED) fell through as generic transport failures, soregisterreported "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 anintoverflow should the permitted range ever widen. - The response status is read before the result is marked successful (TV-XX). A failed
curl_easy_getinfoleftstatusat0whileokwas set totrue, whichclassifythen reported asUnexpectedStatuswith a meaningless code. join_urlstrips every trailing slash (TV-XX). ATOBOR_CLOUD_URLending 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_WRITEFUNCTIONandCURLOPT_WRITEDATAwere 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 fullregister/check/unregisterflow againstmock-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-tidyconfig (TV-XX).-Wshadow,-Wconversion,-Wsign-conversion,-Wcast-qualand-Wold-style-castjoin-Wall -Wextra -Wpedantic; the code is clean under all of them.-Wswitch-enumwas tried and dropped, sinceCURLcodehas 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
#ifndefinclude 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).
Dockerfilewasubuntu:22.04while both workflows pincontainer: ubuntu:20.04to 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_FOLLOWLOCATIONis 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_NOSIGNALis 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 plannedservecommand rather than after it.- The libcurl RAII wrappers own their resources privately (TV-XX).
CurlHandle,CurlSlistandHttpGlobalwere 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::stoireports failure by throwing, which required atry/catchin a header other modules will reuse;from_charsreturns an error code instead, with no exception, allocation or locale dependency. The bounds moved into anIntRangestruct 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 incredential_store.cppand the libcurl options where failure is not actionable. - Assorted correctness-preserving cleanups (TV-XX). Named permission constants in place of the octal
0600literal;std::arrayfor the command table; a bounds-checked offset in place of pointer arithmetic in the credential write loop; an explicit write order insave_credentialin 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 instd::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.mdandREADME.mdupdated to the real/api/robots/...paths, register400(not403) and already-registered handling,osReleaseasPRETTY_NAME, andverify/unregisteras delivered endpoints rather than stand-ins; README version bumped to0.5.1. Docs only.
[0.5.1] - 2026-09-22¶
Fixed¶
- Heartbeat and unregister use the real
/api/robots/...paths (TV-XX). Theheartbeatandunregistertransport 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, andhealthalready matched. SeeCLOUD_BACKEND.md. registernames the already-registered case (TV-XX). The real cloud returns400for an already-registered robot as well as for a bad, expired, or used key; the400message now mentions that case instead of blaming the key alone.unregistermessage is honest when the cloud does not have the robot (TV-XX). The real cloud returns400"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¶
osReleasereports onlyPRETTY_NAME(TV-XX).registersent the entire/etc/os-releasefile asosRelease; it now sends just thePRETTY_NAMEvalue (for exampleUbuntu 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.pynow serves the real/api/robots/...paths, returns400(not403) for a bad register key, uses the delivered response shapes (status/success/message,robotDetails+data,status: "connected"), returns numericstatus: 200in health, distinguishes401/400/403on unregister, and echoesX-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 ofcmd_registerinto the transport: a typedRegisterRequestgoes in and a typedRegisterResultcomes out, withbuild_register_bodyandparse_register_responseowning the JSON field names insidehttp_client.cpp.commands.cppno longer includesnlohmann/jsonor 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. SeeDECISIONS.mdD-0026. checkverifies the credential against the cloud (TV-XX).checknow runsGET /healthand then presents the stored credential toGET /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. Thecheckhelp text is updated to match. SeeDECISIONS.mdD-0028 (amends D-0016, D-0022).- Register reconciled to the real cloud API (TV-XX).
registernow posts toPOST /api/robots/registerwith a body ofregisterKeyplusdevice { serialNo, robotModel, firmwareVersion, ... }, and reads the reply'srobotDetails { id, apiKey }, replacing the old/registerpath, thetokenplusdevice { serial, model, machine_id, os_release }body, and the barecredentialresponse.serialNocomes from the hardware serial;robotModelandfirmwareVersionare 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.machineIdandosReleaseride along as metadata. Matched to the cloud team's delivered contract; pre-1.0 this is a MINOR-level contract break. SeeDECISIONS.mdD-0022, andCLOUD_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 siblingrobot-idfile, both0600inside the0700directory 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)andload_credentialhandle both; a credential present without a readable ID is reported as an incomplete pair, andremove_credentialremoves both files. The durable identity is now the cloud's robot ID; the hardware serial stays the register-time bootstrap only. SeeDECISIONS.mdD-0021 (amends D-0010, D-0011). - Status codes match the real cloud (TV-XX).
classifytreats403as an auth rejection alongside401, andregistermaps400to a "registration key not found, expired, or already used" message. The register retry split is unchanged: a completed4xxis terminal,5xxand transport failures retry. SeeDECISIONS.mdD-0022. - mock cloud matches the real register contract (TV-XX). The mock serves
POST /api/robots/registerwith theregisterKey/device { serialNo, ... }body and arobotDetails { id, apiKey, status }reply, andGET /healthreturns JSON (success,status)./heartbeatand/unregisterremain 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 byregister,check, andunregister, is factored into a singlecloud_url()helper incommands.cpp(the third caller, by the rule of three), matching the existingenv_or/credential_pathconsolidation. runrenamed tocheck(TV-XX). The connectivity command is nowcheck;tobor runreportsunknown command. A CLI-contract break, which pre-1.0 is a MINOR change, reflected in the README andCLOUD_BACKEND.md. The nameserveis reserved for the future always-on online signal. SeeDECISIONS.mdD-0016.- mock-cloud credential format (TV-XX). The issued API key is now
tobor-key-<serial>(earliertobor-cred-<serial>, originally the fixedtobor-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_credentialtransport calls the robot-definedGET /api/robots/verifywith the stored credential as a Bearer token and returns200accepted or401/403rejected (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 dormantsend_heartbeat(whose home is the futureserveloop). SeeDECISIONS.mdD-0028 andCLOUD_BACKEND.md. registerverifies the new credential before reporting success (TV-XX). After saving the credential,registerverifies it with the cloud and reports success only once it is confirmed usable. Pragmatic policy: an outright401/403rejection fails the command; a transient reachability or TLS failure leaves the registration in place and points the operator atcheck; the saved credential is kept in every case. SeeDECISIONS.mdD-0028.- Typed
RobotAuth, single auth-header spot, and the robot ID on the wire (TV-XX). Authenticated transport calls take a typedRobotAuthcarrying both the robot ID and the API key, and the request's auth headers are built in oneauth_headersfunction. It now sendsAuthorization: Bearer <apiKey>andX-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 toX-Robot-API-Keylater) is a one-line edit there.verify_credentialandsend_revokeuse it. SeeDECISIONS.mdD-0027. - mock cloud verifies credentials on
GET /api/robots/verify(TV-XX). The mock serves the robot-defined verify endpoint, returning200for a Bearer credential it issued and401otherwise, and the repeatedBearerparsing in/heartbeatand/unregisteris factored into one helper. In-memory only, dev-only. unregisterdecommissions a robot (TV-XX). Newtobor unregisterremoves the stored credential so a robot can be reset, resold, or decommissioned. It asks the cloud to revoke the credential first (POST /unregisterwith 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 typedyes;--forceskips the prompt for automation. Only the credential androbot-idfiles are removed (/var/lib/toborstays), so re-registering needs no root. Revoke-before-delete is also crash-safe. SeeDECISIONS.mdD-0019.remove_credentialandcredential_pathin the store (TV-XX).remove_credentialdeletes the credential and returns true when the file is gone (deleted or already absent);credential_pathexposes the resolved path so a command can name the file without duplicating the default.save_credentialandload_credentialnow resolve the path throughcredential_pathtoo. SeeDECISIONS.mdD-0019.send_revoketransport and mock/unregister(TV-XX). A newsend_revokeposts to/unregisterwith the credential as a Bearer token, mirroringsend_heartbeat. The mock cloud implements/unregister: it forgets the credential it issued, so a later/heartbeatwith that credential returns401. In-memory only, like the rest of the mock. SeeCLOUD_BACKEND.md.load_credentialsays why a read failed (TV-XX). It now returns a status (Loaded,Missing,Unreadable, orEmpty) instead of just the text or nothing, socheckcan tell "not registered" apart from a permissions problem, a directory path, or an empty file, and report each one plainly. SeeDECISIONS.mdD-0018.- Shared HTTP result classification (TV-XX). A new
HttpOutcomeenum and aclassify()function interpret an HTTP result in one place (TLS failure, unreachable,200, auth rejection, other), soregisterandcheckshare the logic instead of each repeating it. SeeDECISIONS.mdD-0018. - Command table for the CLI (TV-XX). Commands are declared in one table (name, help line, handler), so
--helpand dispatch stay in sync and adding a command is one line. SeeDECISIONS.mdD-0017. - mock-cloud validates heartbeats (TV-XX).
/registerrecords the credential it issues, and/heartbeatchecks the Bearer credential, returning200for a known one and401otherwise, 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 nativeubuntu-22.04-armrunner on every merge to main (plus a manual trigger), ending with a--versionsmoke 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 inDECISIONS.mdD-0014. - Release workflow publishes the aarch64 binary (
.github/workflows/release.yml) (TV-23). Pushing a version tag (v*.*.*) builds the Release aarch64 binary onubuntu-22.04-arm, checks the tag matches the binary's version, and publishes a GitHub Release with a checksummedtobor-<version>-aarch64.tar.gz(the binary namedtoborinside). Also: the build workflow now skips doc-only merges (paths-ignore) and both workflows log the toolchain versions and build verbosely. Tooling only. Rationale inDECISIONS.mdD-0015. - Dev tooling: the mock cloud takes a
--hostflag (default127.0.0.1) so it can bind0.0.0.0and 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
checkhelp and register-path comment (TV-XX). Thecheckcommand's one-line help no longer claims it validates the credential (it checks reachability and local registration), and theregister_robottransport comment names the real/api/robots/registerpath instead of the old/register. Text only, no behavior change. - Credential write is durable across power loss (TV-XX).
write_file_atomicnow writes the.tmpsibling through rawopen/write,fsyncs the file descriptor,renames it into place, thenfsyncs 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. SeeDECISIONS.mdD-0024 (amends D-0010). - POST body is copied into libcurl (TV-XX). The transport sets
CURLOPT_COPYPOSTFIELDS(withCURLOPT_POSTFIELDSIZEset first, so the exact byte count is copied) instead ofCURLOPT_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. SeeDECISIONS.mdD-0025. - libcurl global state is initialized once at startup (TV-XX).
mainnow callscurl_global_initonce before any request andcurl_global_cleanupat exit, wrapped ashttp_global_init/http_global_cleanupsomainstays free of curl (D-0008), with cleanup tied to an RAII guard so it runs on every exit path. This replaces relying oncurl_easy_initto set up libcurl's global state lazily, which is not thread-safe. Single-threaded behavior is unchanged; this disarms a race before the planned threadedserve.--helpand--versionreturn before the guard is created, so they still need no network setup. SeeDECISIONS.mdD-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.mdD-0025 andCLOUD_BACKEND.md. - Credential temp file is created owner-only (TV-XX). The
.tmpsibling is created withopen(O_CREAT | O_EXCL, 0600), so it is0600from its first instant instead of being created at the process umask and tightened to0600afterward. 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. SeeDECISIONS.mdD-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 ishttps://(plainhttp://is unchanged), driven entirely by the URL scheme. Certificate verification is always on and set explicitly (CURLOPT_SSL_VERIFYPEERandCURLOPT_SSL_VERIFYHOST); there is no switch to disable it. A new optionalTOBOR_CA_BUNDLEpoints 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 needsca-certificatesinstalled). tobor presents no client certificate: this is one-way TLS, and mutual TLS remains a Mode B concern. Rationale inDECISIONS.mdD-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 fromCURLE_PEER_FAILED_VERIFICATIONwhen the cloud's certificate is not trusted, orCURLE_SSL_CACERT_BADFILEwhen theTOBOR_CA_BUNDLEfile cannot be read).is_retryabletreats it as terminal, soregisterno longer retries a bad certificate or a mistyped CA path four times, and bothregisterandrunreport 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--keyflags 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 aContent-Lengthheader so a TLS client reads a complete body instead of failing on the connection close. The dev cert and key are generated from a documentedopensslcommand and are git-ignored.
[0.3.0] - 2026-09-01¶
Added¶
- Retry with backoff on
register(../src/commands.cpp) (TV-20).cmd_registernow wraps the one-shotregister_robotin 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 privateis_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 visibleretrying (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 hardwareserial(D-0011), so a repeat updates the same record. Rationale inDECISIONS.mdD-0012. env_int_orhelper and two retry env knobs (../include/util.hpp) (TV-20). New header-onlyenv_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_registeruses it to exposeTOBOR_REGISTER_MAX_ATTEMPTS(1 to 10, default 4) andTOBOR_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 anint.
[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). Newcollect_device_info()returns aDeviceInfowith the robot's hardwareserial(/proc/device-tree/serial-number),machine_id(/etc/machine-id), rawos_release(/etc/os-release), and boardmodel(/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). Theserialis overridable viaTOBOR_DEVICE_SERIALfor dev machines that have no device-tree. Anchor choice and the send-raw-os_releaserationale inDECISIONS.mdD-0011. - mock-cloud
/registeraccepts the JSON payload (TV-19). The mock server parses the JSON body, requires bothtokenandserial(missing either produces a400), validates the token (401on mismatch), records each device byserialin an in-memory registry, and returns the credential on success. Stands in for the cloud's device database.
Changed¶
/registerrequest 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": ...}}, withtokenandserialrequired. The response (a JSONcredential) is unchanged.cmd_registerbuilds the payload withnlohmann/jsonanddump()s it before sending. SeeDECISIONS.mdD-0011.register_robot(base_url, body)(TV-19). The transport's second parameter wastoken; it is now a neutralbody. 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). Newsave_credential(credential)persists the credential from a successful/registerto disk, mirroring the transport seam: the module owns all file mechanics, andcmd_registerjust calls it and checks abool. The write is atomic: the credential is written to a.tmpsibling which is thenrenamed 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 to0600and its directory to0700(owner-only), sized to the same uid the robot's containers run as, sotoborand the on-robot container can read it while other users cannot. The on-disk path defaults to/var/lib/tobor/credentialand is overridable via theTOBOR_CREDENTIAL_PATHenvironment variable, following the D-0005 runtime-config pattern. Storage rationale (location, permissions, plaintext-vs-encryption, container access) inDECISIONS.mdD-0010. (The power-loss durability claim here is fully made good in D-0024, which adds thefsyncsteps.) registersaves the credential and no longer prints it (TV-19). On a200,cmd_registernow callssave_credentialand reportsSuccess!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_orhelper (TV-19). The duplicatedgetenv-or-default logic (a third copy appeared once the credential path was added) was factored into a singleenv_or(name, fallback)in../src/commands.cpp.cmd_registerandcmd_runuse it forTOBOR_CLOUD_URL; the credential path resolves the same way insidecredential_store.cpp, to be unified when the config file lands.
[0.1.3] - 2026-08-31¶
Added¶
registercommand (TV-19).cmd_registerobtains 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 inDECISIONS.mdD-0007.- JSON parse of the
/registerresponse (TV-19). On a200,cmd_registerparses the cloud's JSON body with the vendorednlohmann/jsonand extracts thecredentialfield, replacing the temporary raw-body print. A malformed body, a missingcredentialkey, or an empty credential is reported in plain language and exits non-zero; the parse is wrapped intry/catch, since nlohmann/json signals these by throwing. Parser choice and vendoring rationale inDECISIONS.mdD-0009. - HTTP POST transport (TV-19).
register_robot(base_url, token)sends the token to/registervia a libcurl POST (CURLOPT_POSTFIELDS) and returns the result. - mock-cloud
/registervalidation (TV-19). The mock server now checks the received token against a known-good value: a match returns200+ a JSON credential, anything else returns401+ 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
HttpResultstruct (ok/status/body).check_cloud_healthandregister_robotnow perform the request and return the raw result only;cmd_runandcmd_registerdo the judging (the 200 check) and all user-facing messages. Moves policy and presentation out of the transport layer. SeeDECISIONS.mdD-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 whenpost_bodyis null, POST otherwise - leavingcheck_cloud_healthandregister_robotas thin wrappers. libcurl stays fully hidden insidehttp_client.cpp.
[0.1.2] - 2026-08-28¶
Added¶
- Commands module (
../src/commands.cpp,../include/commands.hpp) (TV-17).cmd_registerandcmd_runextracted frommain.cppinto their own module in thetobornamespace, leavingmain.cppas pure CLI dispatch. First use of the header /#pragma onceconvention (D-0004). - HTTP transport module (
../src/http_client.cpp,../include/http_client.hpp) (TV-17).check_cloud_health(base_url)performs aGET <base_url>/healthvia 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 inDECISIONS.mdD-0006. - Real
runbehavior (TV-17).cmd_runnow 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_URLenvironment 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. SeeDECISIONS.mdD-0005.
Changed¶
main.cpptidied (TV-17). File-private helpers grouped in an anonymous namespace (replacingstatic), and usage-error text factored intousage_error/unknown_command_errorso the wording lives in exactly one place.- D-0004 recorded (TV-17): headers use
#pragma oncefor include guarding, given tobor's fixed gcc/clang target set. SeeDECISIONS.mdD-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. Declaresproject(tobor VERSION 0.1.1 LANGUAGES CXX), enforces C++17 strictly with no compiler extensions, adds../includeas a target include directory, turns warnings into errors in Release builds, and carries the D-0001-lstdc++fsconditional link guard for future gcc 9.4 support. docs/CHANGELOG.mditself (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/--helpand-v/--version(both print to stdout, exit 0); subcommand dispatch routingregisterandrunto stub handlers; and usage-error handling where no command or an unrecognized command prints a one-line message tostderrand exits2. Replaces the CLion hello-world scaffold inmain.cpp. - Compile-time version injection in
../CMakeLists.txt(TV-17).target_compile_definitionspassesPROJECT_VERSIONto the code as theTOBOR_VERSIONpreprocessor definition, sotobor --versionreads the version from the singleproject(... VERSION ...)source with no duplicated literal. - libcurl dependency wired into the build (TV-17).
find_package(CURL REQUIRED)andtarget_link_libraries(tobor PRIVATE CURL::libcurl), linking the system libcurl (OpenSSL flavour, 7.81.0) that matches the robot runtime. Rationale inDECISIONS.mdD-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 endpointsGET /health,POST /register(stub JSON credential), andPOST /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.txtstays atcmake_minimum_required(VERSION 3.16). SeeDECISIONS.mdD-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.)