Skip to main content

The desktop runner

The desktop runner is the machine side of the compute fabric. This page is the developer and operator's view: what the module is, how pairing works, what the governor does, how the signed auto-update is verified, what must change before a release can ship, and how to build and test the tree. The user-facing guide — install, pairing, settings, what data leaves the machine, revoke and troubleshooting — is user-guide/account/desktop-runner.md. The wire contract the runner speaks is the runner protocol; the deployment-side view (the three targets, the selector, turning the fabric on) is compute-fabric.md.

Module layout​

runner/ is a single Go module, and it builds and tests without a GUI toolchain or a container runtime. Two binaries live in it:

  • reelbolt-runner (runner/cmd/reelbolt-runner) — the headless daemon: pair, run, status, pause, resume, doctor, unpair, update, version. It dials out to the runner gateway and never listens on a port.
  • reelbolt-runner-desktop (runner/cmd/reelbolt-runner-desktop) — the Wails tray shell: the same daemon in-process, with a tray and a window. It is a separate Go module, because Wails v3 is a cgo build (gtk4 + webkitgtk on Linux, MinGW-w64 on Windows) and a module's dependencies resolve as a whole — requiring it from runner/go.mod would break go vet ./... on every machine without a GUI stack, which is every CI runner in this repository. See runner/cmd/reelbolt-runner-desktop/README.md for the build command and the two build tags (-tags server, the built-in MCP server) that open a TCP listener and must never be used in a product build.

The packages, and what each owns:

PackageOwns
internal/configrunner.json (0600) under the OS user config dir: gateway URL, device id/name, slots (1 default, 16 max), runtime image, container CLI, per-job limits, the schedule block, update channel/interval/base-URL. Validated on every load — the file is user-editable, and the gateway it names must match the compiled-in allowlist.
internal/identitythe per-device Ed25519 key: OS keyring, or a 0600 device.key file with a logged warning. The private key never leaves the machine.
internal/pairingthe device's half of RFC 8628: pair/start, the poll loop, the result. It talks to the API, not the gateway, and holds no bearer token.
internal/daemonthe daemon: build executors from the capability probe, the governor, the auto-update loop, the SIGTERM/SIGINT drain sequence, the control channel that turns a gateway drain into a local pause/resume.
internal/governorwhen this machine does not work: the [schedule] policy, an explicit pause, and the [limits] a job runs under. status names the reason; see below.
internal/runtime + internal/runtimeimagethe container runtime probe (Docker 24+, Podman 5+, a live-probed --internal no-egress network) and the pinned runtime image. The image reference is a digest when the release pipeline has published one; otherwise --runtime-image, runner.json, then REELBOLT_RUNTIME_IMAGE. Only a pinned digest is advertised in capabilities.runtimeImageDigest, and the gateway refuses a job whose requires.runtimeImageDigest differs.
internal/capsthe capability document the gateway routes on: container kind/mode, job types, ffmpeg version, fontset, max output bytes. A machine with no container runtime advertises video.compile and video.analyze.extract only; remotion.workspace-session needs a verified runtime.
internal/updatethe signed auto-update: manifest format, the Ed25519 verification, the rollout rule, the install/rollback. Its own README is the contract.
internal/uieverything the tray and the window say: the view, the menu, the actions, the first-run pairing wizard, the local API routes (/view, /menu, /do, /settings, /pairing), autostart, the single-instance lock. Plain Go, unit-tested, so a tray whose contents were decided in the Wails host could not disagree with the window.

Where state lives. Configuration: os.UserConfigDir()/ReelBolt/runner.json. Device key: OS keyring or a 0600 file. Job scratch: os.UserCacheDir()/ReelBolt/jobs/<jobId>, deleted when the job ends. Workspaces: os.UserCacheDir()/ReelBolt/workspaces/<id>, bind-mounted into the workspace container. Pause/resume: a small request document in the cache dir, written by pause/resume, consumed (then deleted) by the running daemon. Live runner state: runner.live.json, written while the daemon runs and removed when it stops. Logs: stderr plus logFile if set.

The daemon listens on no port. The only socket is the outbound WebSocket to the gateway; runner/TESTING.md carries the lsof/netstat check a human runs to catch a regression.

Pairing protocol​

Pairing is RFC 8628 (device authorization grant). The machine proves possession of a key it generates locally; the user types a short code into the dashboard and approves the device there; the poll then yields the device id, the organization and the gateway URL.

  1. Start (POST /api/v1/runners/pair/start): the daemon uploads the 32-byte Ed25519 public key, the device name, the OS/arch and its version — what the approver sees — and receives a userCode, a deviceCode, a verification URI and the poll interval. A key already paired on the account is already_paired and the daemon says so.
  2. Approve: the user opens the URI (the code is pre-filled as a query parameter), checks the code against what the machine shows, chooses which workspace the device goes into (its own Personal workspace, or a Team where they are Owner or Admin) and approves. Approving emails every Owner of that workspace. An unknown, expired, used or denied code, and a workspace the caller is not a member of, are all the same 404 — the API refuses to distinguish them (D5), and the dashboard screen refuses to render a 403 where the server said 404.
  3. Poll: the daemon polls no faster than the advertised interval, backs off on slow_down, and stops at the server's expiry (ten minutes). Denied is access_denied; expired is expired_token.
  4. Handshake: run then dials the gateway's WebSocket and the first message is a hello carrying an Ed25519 signature over reelbolt-runner-hello-v1|<deviceId>|<ts>|<nonce>|<gatewayHost> — the host binding stops a hostile gateway replaying the hello elsewhere. The gateway verifies it against runner_devices.public_key and re-checks revoked_at for open connections. The full message catalogue, lease and fencing rules are in the runner protocol.

The private key never leaves the machine and is never sent anywhere. Nothing the pairing flow returns is a bearer token: the later gateway handshake is the signature above, so there is nothing to keep besides the key and the three values the poll returned. Revocation (from the dashboard's Runners page) is immediate — the device is disconnected, receives no more work, and disappears from the list; pairing again is from the machine itself.

The governor: schedules, pause and per-job limits​

internal/governor decides when this machine does not work. It is three things: the [schedule] policy, an explicit pause, and the [limits] a job runs under.

The one invariant: the governor blocks work only for a reason it can evaluate right now. An unreadable control file, an unparsable schedule entry, an idle probe this machine cannot answer, a power source it cannot see — all resolve to allowed, degraded, and the verdict says it was degraded. A runner that silently stops earning because a probe broke is a worse failure than one that works while its user is at the keyboard: the second is visible in status, the first is not. Configuration is the deliberate exception: a runner.json the daemon cannot read or validate is refused at load time with a message naming the field. And a pause never outlives the process that honoured it: restart resumes.

[schedule] gates (all independent; mode is always, idle, ac or windows, with aliases like only-when-idle accepted; an end before its start wraps past midnight):

GateVerdict when blockedSource of the machine state
idle — the user has been away idleMinutes (default 5)user-activeGetLastInputInfo on Windows; logind's system idle hint on Linux, which is best-effort (a session that never sets it reports "unknown", which allows work and says so)
onBattery (pause)on-batteryGetSystemPowerStatus on Windows; /sys/class/power_supply on Linux
windows — {days, start, end}outside-windowthe wall clock

D26 scopes the runner to Windows x64 and Linux x64/arm64; there is no macOS build, and no macOS power API anywhere in this package. A machine the schedule has stopped does not merely refuse work: it drops its connection to the gateway entirely — it is not holding a socket, a heartbeat timer and a lease while it is idle.

Pause and resume. reelbolt-runner pause writes one small request document; the daemon reads it within a second, applies it, and deletes it. resume does the same in reverse, and both wait for the running daemon to acknowledge before printing anything. reelbolt-runner status names the reason a machine is not working, and resume clears an explicit hold without editing a file. Triggers:

  • Local: the two commands, or Daemon.Pause/Daemon.Resume in-process — what the desktop tray uses.
  • Remote: the gateway's drain message. The runner reads its reason: paused_by_user pauses, resumed_by_user resumes, and any other reason is ignored, so a gateway deploy or shutdown can never park a machine. (Nothing on the gateway side sends those reasons yet; the dashboard button is a later WP and needs no protocol change.)

There is no way to pause so the current job finishes first: a pause requeues whatever is in flight, which the gateway handles exactly as it handles any other lost lease.

[limits] — memory, cpus, pids, ffmpegThreads, priority (below-normal: nice on Unix, BELOW_NORMAL_PRIORITY_CLASS on Windows), diskQuotaBytes — were the hard-coded numbers in the daemon; they are configuration now, and no executor holds a literal. They land as --memory / --cpus / --pids-limit on container jobs, as nice/-threads N on host ffmpeg, and as the session's scratch quota. Values larger than the machine are clamped (CPUs to the core count, memory to 80% of RAM) and the clamp is logged and shown by status rather than refused; a value outside the sane range (memory under 256 MiB, more than 64 CPUs, a process count under 16) is refused at load.

Signed auto-update​

reelbolt-runner update --check reports what the release channel offers; update --apply downloads, verifies and installs; update --rollback restores the binary the last update replaced. The daemon does the same check in the background every updateIntervalHours (default 6, max 168) when autoUpdate is on (the default), and applies a release only while no job is running — the in-flight count is the same gate at the moment of the swap as at the moment of the check, so an update never interrupts a render. After a successful install the daemon stops and a supervisor (systemd user unit, the Windows service) restarts it on the new binary.

An unreachable update host is not a failure of the runner. The check fails, the daemon logs it and keeps serving jobs; the next check retries. A signature that does not verify is refused the same way: nothing is downloaded or installed.

Nothing is installed that was not verified first, in this order: the compiled-in Ed25519 release key over the manifest (two slots, current and next, staged by ldflags at release-build time), the SHA-256 and exact byte length of the artifact, the operating system's own signature on Windows, and a re-hash of the staged file at the moment of the swap. A download is written to a .part file and renamed into place only when complete and correct.

This tree holds no release key. Both slots are empty in every build made from this source, so status and doctor report keys none (this build refuses to update) and every update is refused before the network is touched. The release pipeline (E10b) is what stamps a key and publishes the manifest and its signature; internal/update/README.md is the manifest format, signature, rollout rule and how to publish a release. The update settings in runner.json are updateChannel (stable/beta), autoUpdate, updateIntervalHours and updateBaseUrl (a host outside the product domain needs --dev-update-host on a non-release build).

Release status and what E10b must supply​

The desktop shell is built and cross-compiled (Windows x64, Linux x64/arm64 — the latter two are compile-and-vet proofs, not executions; nothing in this repository runs a Windows binary). What is true of the desktop product today, stated plainly:

  • No signed installer exists. There is no release key in this tree, so no signed artifact has ever been produced. The marketing site's /runner page therefore ships in its "not available yet" state: its download list is driven by site/lib/runner-downloads's runnerArtifacts, which is empty, and no build can advertise a download that does not exist.
  • The signed auto-update refuses everything for the same reason: an empty trust root is ErrNoTrustedKey, and the refusal happens before the network is touched.
  • No desktop session has been started in this project. The tray is built and cross-compiled; no icon has been displayed. runner/TESTING.md is the human script — tray states, the window, the login item, the no-listening- sockets check — that a release build runs against a real session on Windows 11 and Ubuntu 24.04.
  • The cloud pool is not in service. The reelbolt-worker binary exists (sandbox/cmd/reelbolt-worker, with the out-of-band enroll subcommand) and infra/worker/cloud-init.yaml is the fragment that runs it, but no running deployment enrolls CloudPool runner_devices rows, so a pool job stays Queued. See cloud-worker.md for the enroll command and what a provider launch (F12) must supply.

So the criteria that need a real pipeline, key or account are: a stamped release key (E10b), a published signed manifest per channel (E10b), a real desktop session for the human test script, and a cloud provider with credentials for the pool (F12). Nothing in this tree can produce those; none of them is faked here.

Building and testing the tree​

# The default suite needs no container runtime and no container.
docker run --rm -v "$PWD:/src" -v reelforge-go-modcache:/go/pkg/mod \
-w /src/runner -e GOPROXY=off golang:1.26-alpine \
sh -c 'apk add --no-cache build-base; gofmt -l .; go vet ./...; go test ./... -race -timeout=15m'

The mount is the repository root, not runner/ alone: runner/go.mod has replace .../sandbox => ../sandbox, so mounting runner/ fails with "replacement directory ../sandbox does not exist". The signed auto-update runs entirely offline against local test servers (go test ./internal/update -race).

The real-container tests — a real workspace container from the runtime image, a real Remotion CLI run inside it, a real ffmpeg encode — are a human's:

REELBOLT_RUNNER_DOCKER_TESTS=1 go test ./internal/daemon -run Real -v -timeout=30m
REELBOLT_RUNNER_DOCKER_TESTS=1 REELBOLT_TEST_REMOTION_RENDER=1 go test ./internal/daemon -run Real -v -timeout=45m

The desktop module is built separately (GUI toolchain required — see its README); cross-compilation of the daemon for all three D26 targets is in runner/TESTING.md and is a compile-and-vet proof, not an execution. The TestRunnerHandshakesWithTheGateway test is known-intermittent; re-run it before blaming it.