@ship.zone/runner
v2.0.0
Published
The isolated OCI job runner for ship.zone CI/CD
Readme
@ship.zone/runner
@ship.zone/runner is the outbound-only OCI execution agent for ship.zone CI/CD. It implements the
runner protocol of @ship.zone/ci-spec (2.0.0) for the oci execution profile, validates leases
and compiled jobs against the published schemas, and runs one fenced attempt at a time on a local
Docker or Podman engine.
The control plane owns workflow compilation, scheduling, authorization, secrets policy, and canonical run state. This package owns runner registration, lease handling, isolated execution, bounded transfers, redacted logs, and terminal reporting.
Issue Reporting and Security
For reporting bugs, issues, or security vulnerabilities, please visit community.foss.global/. This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a code.foss.global/ account to submit Pull Requests directly.
Security Model
- Only local Unix-socket Docker and Podman endpoints are accepted. Remote contexts and connections are rejected before capabilities are advertised.
- Job images must be Linux images pinned by an exact SHA-256 digest. The image OS, architecture, and digest are checked before lease acceptance.
- Every compiled step runs in a fresh container as a numeric non-root user with a read-only root,
all capabilities dropped,
no-new-privileges, private IPC, and CPU, memory, PID, shared-memory (/dev/shm), time, and writable-storage limits. The job'sresourcesare these ceilings; omitted values take the runner defaults, and every value is bounded by the maximum the runner advertises. - The only network mode is
none: every step container runs in a new network namespace whose only interface is its own loopback, so nothing leaves the sandbox and loopback ports never collide with another attempt. The runner exposes no service on that loopback.egressallowlists are not enforced by this runner, are never advertised, and cannot be configured. - Job containers receive no host paths, runtime sockets, devices, host namespaces, or arbitrary OCI
arguments. Their only persistent writable mount is
/workspace. - The shared workspace is an attempt-specific
nodev,nosuidtmpfs with hard byte and entry ceilings. Imported executable files may run directly from it. A trusted static Rust helper imports source/cache staging, measures the workspace around every step, and exports only declared artifact/cache paths. - Helper containers use the exact job image only as an inert root filesystem. They override the
entrypoint with the packaged static helper, have no network, and receive only the narrowly scoped
CHOWNandDAC_OVERRIDEcapabilities required for private staging ownership. Job containers do not receive those capabilities. - Step containers receive exactly the compiled step environment plus job secrets. Secret values are passed through the runtime environment rather than OCI command arguments.
- The attempt's redaction set holds every secret value, every npm grant token, and each line of a
value containing a line terminator (CR LF, LF, or CR), leaving out members shorter than 8 UTF-8
bytes; a short line of a multi-line value stays unmasked when printed on its own. Every member is
replaced with
***in log output, per stream and across the attempt's interleaved chunk sequence, so no chunk and no concatenation of consecutive chunks contains one. An artifact or cache archive whose entry path, link target, or file content contains a member is never published: a required artifact or a cache completes the attemptsystem_error, an optional artifact is omitted and reported. Redaction matches exact bytes; a job that transforms a value can still disclose it, so the coordinator must not grant protected secrets to untrusted jobs.
Requirements
- Deno 2
- pnpm 11
- Docker or Podman on Linux
- A rootless runtime by default
- Rust and configured cross-compilers when building the packaged helpers from source
Install
Add the published source package to a Deno project:
deno add npm:@ship.zone/runnerThen import the package through the generated import-map name. The npm export resolves to the prebuilt Deno JavaScript bundle and its rolled-up TypeScript declarations:
import { ShipRunner } from '@ship.zone/runner';Without an import-map entry, use the npm package directly:
import { ShipRunner } from 'npm:@ship.zone/runner';For development in this repository, install dependencies and build the npm bundle plus the static
musl-based linux_amd64 and linux_arm64 helpers:
pnpm install
pnpm buildThe published package includes dist_rust/runnerworkspace_linux_amd64_musl and
dist_rust/runnerworkspace_linux_arm64_musl, the Rust source and manifests,
rust/.cargo/config.toml, and the dependency notices. oci.workspaceHelperPath can select an
explicit absolute helper path for development or controlled deployments.
Configured, packaged, and dependency-injected helper resolver paths are source files. Runtime
verification reads the bounded regular source through one pinned file descriptor, hashes those exact
bytes, and materializes an executable mode-0755 copy below the private mode-0700 runner state
subdirectory as workspace-helpers/runnerworkspace_<target>_musl_<sha256>. Source permissions are
not changed, so package managers may install the source without executable bits. The private parent
directory is the host access boundary; mode 0755 lets capability-dropped helper containers execute
the host-owned read-only bind mount. IOciRuntimeFacts.helperPath reports the validated
materialized path used by host probes and OCI bind mounts, while helperSha256 reports its content
digest. A private advisory lock serializes materialization. Existing digest paths are reused only
after their type, single-link status, mode, and digest are revalidated without mutating them; the
runner retains a bounded set for rollback, reclaims stale incomplete temporary copies, and rejects
excessive recent temporary residue. The sole immediate-recovery exception is a validated temporary
name and exact digest destination that still reference the same two-link inode after interruption;
the runner removes the temporary name under the lock, verifies the destination became single-link,
and then reuses it.
Runtime verification creates one private .runtime-probe-owner_<id> marker in the helper state.
Host tmpfs probes include that owner in their exact directory names, and OCI runtime probe volumes
carry the same ship.zone.runner.runtime-probe-owner label. This lets interrupted verification be
cleaned by the same state owner without touching unowned probes or probes from another runner. The
runner syncs both the helper-directory entry and a newly created owner marker before using them.
Configure And Run
Create a private runner configuration:
deno run --allow-all \
npm:@ship.zone/runner init \
--control-plane https://foss.global \
--runtime-path /usr/bin/dockerRegister with a one-time bootstrap token, then start the daemon:
SHIP_RUNNER_BOOTSTRAP_TOKEN='<one-time token>' \
deno run --allow-all \
npm:@ship.zone/runner register
deno run --allow-all \
npm:@ship.zone/runner run--allow-all is required because Deno reserves direct /dev/shm access for that mode. The runner
uses /dev/shm for fail-closed host tmpfs verification before registration and must already be
trusted with unrestricted subprocess execution for the configured OCI runtime.
Replace run with once to claim at most one job or config to print the effective configuration
with credentials masked. Within this repository, deno task dev -- <command> is the equivalent
development invocation. SHIP_RUNNER_CONFIG selects a config path; the default is
./runner.config.json.
init writes the config with mode 0600. Registration verifies the runtime and protocol discovery
document before advertising capabilities, durably journals the exact registration request without
the bootstrap token, then atomically replaces the bootstrap token with the returned runner
credential. An interrupted registration replays the journaled request and rejects configuration
drift. Credential rotation from session and runner-heartbeat responses is persisted before the new
credential becomes active.
The default policy includes:
- rootless Docker at
/usr/bin/docker; - labels for Linux, the current architecture, and OCI;
- network
noneonly; - job user
65532:65532; - a 4 GiB maximum logical workspace and 100,000 entries;
- bounded source, artifact, cache, log, environment, and staging limits;
- a 1 GiB free-space and 10,000-inode staging reserve.
Plain HTTP control planes are rejected except for explicitly enabled loopback development URLs.
Execution Lifecycle
For each claim, the runner:
- Validates the lease against the published
JobLeaseschema, the compiled job against the runner job schema and the specification version acceptance rule, the RFC 8785compiledPlanDigest, internal job coherence, the lease coherence ofgrants,bindings, andnpmGrants, fit against the immutable advertised capability snapshot, and all runner execution policy before image inspection or pull. A lease that fails any check, or that this runner does not execute, is abandoned asrunner_errorbefore acceptance. - Pulls or locates the exact image digest, validates its Linux architecture, and probes it with the inert helper.
- Writes a private interrupted-attempt journal and sends the explicit fenced acceptance request. Source transfer and execution do not start before HTTP 204.
- Downloads and validates the source
tar.gz, then restores readable caches in declaration order into private host staging. - Imports staging into the bounded tmpfs and runs compiled steps in declaration order, stopping at the first non-zero exit.
- Publishes successful read-write caches and artifacts selected by their
whenpolicy as deterministic, replayabletar.gztransfers. - Cleans staging, helper/step containers, and the tmpfs volume before sending the idempotent terminal completion, then clears the recovery journal after the coordinator accepts it.
An attempt-scoped inert holder container keeps Docker's local-driver tmpfs mounted while fresh step and helper containers come and go. It runs no image-provided command and is removed with the workspace.
On restart, a new session first removes stale labeled containers and volumes locally, scavenges only the runner's known host-staging directories and journal temporary files, then reads the secret-free journal and calls the recovery endpoint to revoke and reconcile the interrupted attempt. Work is never resumed, and a coordinator outage cannot retain prior staging.
Job Timeout
The attempt deadline is the job's timeoutMs, measured with a monotonic clock from the moment the
runner receives HTTP 204 for its acceptance. It covers source extraction, cache restoration, and
every step. When it is reached before execution ends, the runner terminates execution as for a
cancellation, force-stopping within the configured stop timeout of at most 30 seconds, archives the
artifacts declared when: always or when: failure, publishes no read-write cache, and completes
timed_out. Artifact collection and upload, cache publication, and completion follow execution and
are bounded only by the fence. Whichever of an accepted cancellation and the deadline comes first
decides the outcome: canceled or timed_out.
Cancellation And Fencing
Job heartbeats renew the coordinator-time lease and acknowledge cancellation directives. A
cancellation immediately begins termination, bounds TERM-to-KILL by the coordinator's terminateBy
deadline, and completes with the same cancellation request ID. Heartbeats continue during bounded
termination.
HTTP 409 fencing loss, authentication loss, or coordinator-time lease expiry stops work and suppresses subsequent logs, transfers, and completion. Graceful runner shutdown abandons the exact accepted identity instead of reporting a successful terminal result.
Logs use nested fenced identity, zero-based sequencing, exact acknowledgements, streaming secret
redaction, and UTF-8 byte limits. Reaching the negotiated total log limit fails closed and
terminates the attempt as system_error; output is never silently truncated.
Runner Protocol
The runner implements the specification version of the @ship.zone/ci-spec package it is built
against (its ciSpecVersion, currently 2.0.0), and sends it as spec in registration and session
requests. It refuses a coordinator whose discovery, registration, or session spec has another
major version, so a 2.x runner never talks to a 1.x (@foss.global/ci-spec) coordinator, and a
coordinator answers a runner of another major version with HTTP 409 spec_incompatible. Every
capability the runner advertises is defined in 2.0.0, so it sends the same capability snapshot to
every coordinator of major version 2. A leased job is accepted only when its spec has the same
major version and is not newer than the implemented version; any other job is abandoned as
runner_error.
The runner advertises the oci profile, container isolation, the native linux/<arch> platform
of its verified OCI engine, the none network mode, its transfer limits, resources maxima for
memory, CPUs, PIDs, and shared memory, an empty leaseFeatures list because it uses no image pull
grants, candidate bindings, or npm read grants, and maximumTimeoutMs, its configured
oci.maximumTimeoutMs. It refuses, by abandoning the lease as runner_error before acceptance,
every lease it cannot execute as specified: the vm and oci-image profiles, microvm isolation,
egress networks, candidate images with bindings, image grants, jobs with npmRead and their
npmGrants, and jobs whose timeoutMs exceeds oci.maximumTimeoutMs.
The reserved step environment names are SHIPZONE_CI_MATRIX_JSON and SHIPZONE_CI_INPUTS_JSON,
each carrying canonical RFC 8785 JSON. A job whose step environment defines any other
SHIPZONE_CI_* name, or whose secret targets use any SHIPZONE_CI_* name, is rejected.
All endpoints are below /api/runner. Registration uses the one-time bootstrap bearer token;
subsequent requests use the renewable runner credential. Every request body is validated against its
published OpenAPI component before it is sent, and every response before it is used.
| Operation | Endpoint | Idempotency |
| ---------------- | --------------------------------------------------------- | ------------------------------------------ |
| Discover | GET /api/runner | Public exact descriptor |
| Register | POST runners/register | registrationRequestId and canonical body |
| Start session | POST runners/{runnerId}/sessions | sessionRequestId and canonical body |
| Runner heartbeat | POST runners/{runnerId}/heartbeat | Current runner session |
| Claim | POST jobs/claim | claimRequestId and capability snapshot |
| Accept | POST jobs/{jobId}/attempts/{attemptId}/accept | acceptRequestId and full identity |
| Job heartbeat | POST jobs/{jobId}/attempts/{attemptId}/heartbeat | Fenced identity |
| Log | POST jobs/{jobId}/attempts/{attemptId}/logs | Attempt and sequence |
| Complete | POST jobs/{jobId}/attempts/{attemptId}/complete | completionRequestId and terminal body |
| Abandon | POST jobs/{jobId}/attempts/{attemptId}/abandon | abandonRequestId and canonical body |
| Recover | POST jobs/recover | Current session and recoveryRequestId |
| Source | GET sources/{sha256} | Immutable attempt-bound digest |
| Artifact | PUT jobs/{jobId}/attempts/{attemptId}/artifacts/{name} | transferRequestId and metadata |
| Cache | GET/PUT jobs/{jobId}/attempts/{attemptId}/caches/{name} | Fenced declaration and transfer ID |
Transient retry statuses are 429, 502, 503, and 504. Exact retries reuse the same request ID and canonical body. Request, response, archive, extracted-data, entry, and stream sizes are bounded. Each source, artifact, or cache archive also has a fixed 134,217,728-byte metadata ceiling across its normalized UTF-8 entry paths and symbolic-link targets. Implicit parent directories count against the workspace entry limit, and symbolic-link validation retains at most 1,000,000 canonical path components.
Library API
The package exports its runner components for controlled embedding:
import {
ArtifactManager,
CacheManager,
defaultRunnerConfig,
OciExecutor,
RunnerApiClient,
ShipRunner,
SourceWorkspaceManager,
} from '@ship.zone/runner';The CLI is the supported complete assembly. Embedded callers must preserve the same runtime verification, discovery, lifecycle, fencing, cleanup, and credential-persistence ordering.
Verification
deno task fmt --check
deno task lint
deno task check
pnpm test
pnpm build
pnpm pack --dry-runThe real Docker integration is opt-in and requires a locally available digest-pinned Linux image:
SHIP_RUNNER_OCI_TEST=1 \
SHIP_RUNNER_OCI_IMAGE='alpine@sha256:<digest>' \
deno test --allow-all test/oci-executor.test.tsIt verifies direct execution of an imported workspace file, shared multi-step state, helper ownership, selected export, hardlink rejection, inode exhaustion, and container/volume cleanup against the real runtime.
License and Legal Information
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the repository license file. Dependency notices for the npm bundle and the packaged static helper binaries are in third-party-licenses.md, with the complete applicable musl inventory in musl-copyright.md.
Please note: The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
Trademarks
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH or third parties, and are not included within the scope of the MIT license granted herein.
Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.
Company Information
Task Venture Capital GmbH Registered at District Court Bremen HRB 35230 HB, Germany
For any legal inquiries or further information, please contact us via email at [email protected].
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.
