Matelotdocs
Runners

Runner ↔ cell protocol (v1)

A runner is a process (Docker container, VM service, Kubernetes pod) that executes jobs for one cell. It only ever makes outbound HTTPS calls to the cell's API (<web url>/api/v1/runner/…); the cell never connects to the runner, so runners work behind NAT and firewalls. Reference implementation: apps/runner (TypeScript); the protocol is small enough for a Go/Rust agent.

Where to run it

Docker, Docker Compose, a Kubernetes Deployment (manifest generated by the Register dialog), or from source. The runner needs outbound HTTPS to the platform and network access to what it scans (git host, registry, cluster). It does not need to share a network with the platform.

Registration

POST /runner/register { registrationToken | managedSecret, name, tags[], version, metadata } (self-hosted: a registration token from the Runners page; the cell's managed runners: MANAGED_RUNNER_SECRET, same name replaces the previous record — see managed-runners.md) → { runnerId, runnerToken, pool }. Registration tokens come from the Runners page (one-time, 24 h). The runner token (rt_…) is the runner's identity; keep it in a 0600 file/volume and present it as Authorization: Bearer rt_… on every other call.

Liveness

POST /runner/heartbeat every 15 s { metadata? }. A runner silent for 60 s is marked offline and its job is requeued.

Getting work

POST /runner/jobs/claim { waitSeconds ≤ 25 } — long poll. 200 with a job the cell has atomically assigned to this runner (matching: same organization, pool on the project's path, required_tags ⊆ pool.tags ∪ runner.tags, highest priority first, oldest first) or 204. The assignment carries a 60 s lease.

Running a job

  1. POST /runner/jobs/:id/start — job becomes running.
  2. POST /runner/jobs/:id/heartbeat every 20 s — extends the lease; the response says whether the job was cancelled (stop work, no completion call needed).
  3. POST /runner/jobs/:id/credentials — for repository jobs: { kind: 'https'|'ssh'|'none', url, username?/secret? | privateKey, defaultBranch, fullName }, minted just in time (GitHub App installation tokens live 1 h). Never persisted on the runner.
  4. POST /runner/jobs/:id/logs { chunks: [{ seq, stream: stdout|stderr|system, content }] } — batched, idempotent by seq.
  5. POST /runner/jobs/:id/complete { status: succeeded|failed|skipped, result?, error?, exitCode? } — skipped means "this runner does not implement the job type"; a pipeline treats it as non-failing.

If the lease expires (runner crash), the sweeper requeues the job (attempts < maxAttempts) or fails it; jobs also time out at timeoutSeconds.

Job types

repository_check (clone, report HEAD/branch/size — the smoke test of the chain), custom, sbom (payload target: source|image, ref / imageRef, optional version: Syft → CycloneDX, uploaded with POST /runner/jobs/:id/sbom; image jobs fetch registry credentials with POST /runner/jobs/:id/registry-credentials), kubernetes_inventory (payload namespace, optional context: lists deployments / statefulsets / daemonsets / cronjobs and running-pod image digests with kubectl; the cell turns matching images into deployments of the environment — requires a runner with cluster access, tag kubernetes) (payload.command run with sh -c, in the clone when a repository is attached). Step 04+ add sbom, sca, sast, secrets, iac, container_scan, … each with a results contract.

Security properties

  • Runners get only jobs created through the platform (RBAC-checked at creation); the token is bound to one pool and therefore one organization subtree.
  • Credentials are fetched per job, kept in memory/temp files with 0600, deleted after the job.
  • Logs are scrubbed of https://user:token@ and Authorization: Basic … before upload.

Register and heartbeat responses carry config — registryHostAliases (the cell tells containerised runners of a local cell to reach localhost:5000 as host.docker.internal:5000; merged with the optional env REGISTRY_HOST_ALIASES) and latestVersion (the agent warns when outdated; the Runners page flags it). Runners report metadata.container (from /.dockerenv or in-cluster) and their version on every heartbeat.

Automatic image SBOMs are queued once per tag; after a failure the tag is not retried automatically for 12 hours (retry from the job page). Syft errors are reported verbatim in the job log and error field.

secrets / iac jobs run Trivy offline on the checked-out repository and upload normalised findings with POST /runner/jobs/:id/code-findings ({ kind, ref, findings[] }; evidence redacted on the runner and again in the cell). The cell closes findings of that kind that the scan no longer reports.

Assignment guarantees

  • A job is constrained only by the pool requested when it was created (requested_pool_id); where an attempt ran (pool_id) never binds a requeue.
  • A claim whose connection closes is dropped; a job assigned to it, or whose claim response was not delivered, goes back to the queue at once without counting the attempt.
  • An assignment not started within 30 s returns to the queue; a started job is requeued when its 60 s lease expires (up to maxAttempts).
  • On SIGTERM the agent aborts its open claim and never starts a new job.