Executors
Included from Basic tier
An executor is what actually runs a step once it has been dispatched — on a
runner, or in-process in local mode. There are two kinds, sharing one
contract (workspace layout, step_context.json in / step_result.json out,
environment assembly, log masking), so a step behaves the same wherever it
lands:
| Kind | Runs the step as |
|---|---|
hostexec | A host process under the invoking OS user — the local-mode default. No security boundary beyond that user: an isolated workspace (per step, or shared per run for a run-scoped run), an explicit host-env allowlist, the engine's own opschain shim on the step PATH, process-group spawn so cancellation reaps the whole tree. darwin/linux only today. |
containerexec | One container per step over the Docker Engine / Podman compatibility API: workspace bind-mounted at /opt/opschain, non-root (1000:1000 by default), no-new-privileges, optional memory/CPU limits. |
Advertising and auto-detection
A runner advertises the kinds it can serve (opschain runner --executors):
auto(the default, same as an empty list):hostexecalways, pluscontainerexecwhen a container runtime is detected at startup.- An explicit list (
hostexec,containerexec): namingcontainerexecwith no reachable runtime fails startup —cannot advertise containerexec: no container runtime detected: …— a runner must never advertise an executor it cannot serve. An unknown kind fails withunknown executor kind "<k>" (supported: hostexec, containerexec, or the single value auto).
Runtime detection probes, in order: DOCKER_HOST when set (alone),
otherwise unix:///var/run/docker.sock, the per-user Docker Desktop socket
(~/.docker/run/docker.sock), $XDG_RUNTIME_DIR/podman/podman.sock and the
rootless Podman machine socket — each pinged with a 2 s timeout, first
answer wins. The failure names every endpoint tried; opschain doctor
reports it verbatim when the capability is unavailable.
How a step demands an executor
A step does not name an executor directly: a step declaring image: runs
under containerexec; every other step keeps hostexec. The demand is
resolved when the run's graph is compiled and frozen into the step's
persisted exec metadata, and at enqueue it is folded into the step's
placement requirement, so only runners advertising that kind are eligible
(opschain docs fleet-queue). A containerexec step whose spec carries no
image is refused at Prepare: containerexec requires an image: set the step's image or run under hostexec.
The workspace a step sees
Each step-scoped step attempt gets its own workspace at
<home>/work/<run>/<step>/; every step of a run-scoped run (the default for actions.rb changes and for declared changes carrying
any Ruby-lane step — both only applicable to existing MintPress customers
and those using the MintPress SDK or the packs:mintpress feature — or
runner.execution_scope: run; in
--local mode every change run is run-scoped, so files written
by one step reach the next) shares one workspace at
<home>/work/<run>/shared/,
which is also the step's working directory (mounted at /opt/opschain
inside a container — the path opschain-core hard-codes for its traffic
files). A step's declared artifacts: are collected FROM this workspace at
the step's completion, before per-step cleanup or the run-end sweep can
remove it (opschain docs artifacts). Into it the executor materialises:
- The checkout — the pinned repo content. In
--localmode it is a linked git worktree of the source repository:git describeresolves the pinned commit and refs written inside (e.g. a releasegit tagyour action creates) land in the real repository, while the engine's traffic files stay out ofgit statusonly where the repo ignores them (this repo's committed.opschain/.gitignore). On a runner the tree was fetched from the server's git mirror and materialised as a plain tree (no.git); seeopschain docs runner. Either way the step never runs in the source working copy itself, so traffic files cannot pollute it. .opschain/— the traffic directory:step_context.json(written at Prepare), its out-of-band schema-version siblingstep_context.version, and thestep_result.jsonthe step writes..opschain/mintmodel_actions.json— only when the job stages one: the formatted MintModel actions a catalog re-export carries in its prepare spec, written verbatim at Prepare for opschain-core'sOpsChain.mintmodel_actions(step names/paths only — never secret values — so it is world-readable and joins no mask set). A spec without the payload removes any stale copy. A runner build that predates the staged file instead receives the payload base64-encoded inside the job command, where a catalog over the 96 KiB command budget degrades to no file — an empty list to actions.rb — and records thewarn:mintmodel:actions_unavailableevent; updating the runner removes that budget..opschain/packs/<name>— each action pack the step's compiled graph references, as read-only inputs;$OPSCHAIN_PACKS_DIRnames the directory in the step's own context, and a run command's${{ pack.dir }}is resolved to it at spec build.
A step-scoped workspace is removed after Collect unless the run asked to
keep it for debugging (--keep-workspace on a local run).
A run-scoped workspace is prepared once — the run's first step
materialises the checkout and packs (the pinned SHA is per-run, so one
checkout serves every step), under a per-workspace prepare lock so
concurrent steps (below) never double-snapshot — and reused by every later
step of the run: per-step Cleanup leaves it in place, each reuse resets the
previous step's shared traffic files (step_result.json) while
step_context.json and opschain.files still materialise per step, and the
whole <home>/work/<run>/ directory is removed at run end (on a runner,
the submit response of the run's last step reports run_finished; in
--local mode the attached drive's end sweeps it, worktree registration
included, unless --keep-workspace retains it) — terminal outcome, cancel
included. Each step's own data-passing files — the
output/property/event files and its context mirror — live in a per-step
directory, .opschain/steps/<step>/, created at Prepare and removed by that
step's Cleanup, so two steps of one run never share a data-passing file.
The run is pinned to the runner its first step landed on
(opschain docs dispatch), so the shared workspace and the runner are one:
software staged to disk by one step is on disk for the next. A runner build
that predates run-scoped workspaces degrades to per-step workspaces — loudly,
via a system line in the step's log — and the run still executes.
With runner.max_parallel_steps above 1 (opschain docs settings) the
pinned runner executes up to that many steps of the run at the same time
in this one shared workspace — the per-step data directories are what keeps
their outputs, property writes and collected results apart. hostexec action
steps are the fully concurrent lane (opschain-core's own traffic is
per-step, anchored under the step's $HOME/.opschain/<run>/<step>/); a
containerexec step's gem contract is the shared un-segmented
.opschain/step_context.json/step_result.json, so a containerexec job of
a concurrent run executes exclusively — the runner gates it so no
sibling prepares, runs or collects while it is in flight. Concurrent steps
sharing the workspace still share the checkout's files: writes a step makes
to the tree itself (opschain.files directives included) are the action
author's to coordinate, exactly as in upstream's change pod. Action code
relying on a pre-0.1.0.895 opschain-core (no per-step home traffic) must
stay at the serial default.
Command resolution
Exactly one of command/action must be set — both or neither is an error
(run spec sets both command and action: exactly one must be set / …sets neither…). A shell step runs /bin/sh -c <command>; an action task is
reserved for a future integration.
The execution environment
Assembled lowest precedence first: allowlisted host variables, the engine-supplied spec env, injected secret values, then the executor-internal variables. On top of that:
- hostexec passes through an explicit host allowlist only:
HOME,TMPDIR,TMP,TEMP,LANG,LC_ALL,LC_CTYPE,TZ,USER,LOGNAMEand the proxy variables (HTTP_PROXY/HTTPS_PROXY/NO_PROXY, upper and lower case). The runner's--env-passthrough/OPSCHAIN_RUNNER_ENV_PASSTHROUGHextends this allowlist — the runner-in-container contract, for toolchain configuration every step needs.PATHis the spec's when given, the host's otherwise, with resolved tool-shim directories prepended and the engine shim directory (<home>/cache/engine/bin, holding anopschainsymlink to the running engine binary) after them — soopschain stepsubcommands always resolve to the engine that dispatched the step. - containerexec passes through no host environment and resolves no
tool shims — a container step's tooling is its image.
PATHis only set when the engine supplies one; the image's stands otherwise. - Every step gets
OPSCHAIN_CONTEXT_SCHEMA_VERSION, and the step data-passing file variables pointing into its own data-passing directory (the traffic directory itself for a step-scoped workspace,.opschain/steps/<step>/inside a shared run-scoped one):OPSCHAIN_OUTPUT,OPSCHAIN_OUTPUT_SENSITIVE,OPSCHAIN_PROPERTIES,OPSCHAIN_EVENTS(theopschain stepsubcommands write to these). - Every server-dispatched step gets
OPSCHAIN_STEP_TOKENandOPSCHAIN_API_URL: a step-scoped API bearer plus the address the runner reached the server on, so action code can call back into the OpsChain API as the run's initiating principal — never as the runner. The token is valid only while this step attempt's lease is live: minted at dispatch, renewed with the lease, revoked when the step finalises (success, error, cancel or runner lost alike), and audience-bound to the customer API surface. Calls made with it are attributed to the step in the request log (token kindstep), and a change or workflow run it creates joins the invoking run's correlation chain as astep-initiated hop instead of rooting a fresh chain. The value rides the secret-injection path, so it is masked out of logs like any secret. No new HTTP surface carries it — the token travels in the existing runner job spec and authenticates through the existing/apibearer seam (the bearer vocabulary gained theocst_credential class, owed a v2 functional equivalent before any v1 sunset). - A step whose command references
OPSCHAIN_LICENCE_DOCUMENTgets the estate's applied licence document in that variable, by value: the signed licence envelope exactly as it was uploaded, picked as the latest-expiring applied licence that grantstools:rpcu,packs:rpcorpacks:infrastructure. Pack action wrappers hand it to tools that verify LimePoint licences offline (their--licence-envchannel). The value rides the secret-injection path and is masked out of logs; with no granting applied licence the variable is simply not set and the tool falls back to its own ambient licence. A local (--local) run makes the same hand-off with the same selection rule from the licence files on disk: every*.licencefile under$OPSCHAIN_HOME/licencesis one applied document (re-verified on every read), and those files also satisfy the pack entitlement gate when abundle:-mounted pack requires one. - A step whose command references
OPSCHAIN_TRANSPORT_SESSIONgets a one-shot transport session in that variable: the credentials document the pack's rpc scripts pipe to the tool, minted at dispatch and masked out of logs — credentials never live in properties or the step context. On the server lane the session comes from the registered transport provider governing the step's target, with the caller's transport grant enforced. On a local (--local) run it comes from$OPSCHAIN_HOME/transport-providers.yaml: each entry names acode, akind(ssh-static, the default, orssh-ca), acredentialthat must be asecret-vault://reference into the local secret store, an optionalauth_method(key, the default, orpassword) forssh-static, andusername/principals/validity_secondsforssh-ca. The deepestrpc.provider_refproperty picks the entry, else the single defined one; several entries with no selecting property refuse by name, as does a missing file, an undefined code or a credential that does not resolve. Editing the file forces fresh tool-side dials (its modification time is the session pool's generation). - The secret vault for templates and actions rides this same contract:
OPSCHAIN_VAULT_*variables (address, auth_method, token, mount_path, password_auto_create, ssl_verify...) configure theSecretVaultthat MintModel templates (SecretVault.get_password(...)) and actions call at render/execution time. Vault credentials live with the RUNNER, never in the settings system (a settings-stored vault credential would need the vault to decrypt itself). Two sources, atomic per provider: variables set on the runner's own process at startup (handed through via the env passthrough — the example image's entrypoint extendsOPSCHAIN_RUNNER_ENV_PASSTHROUGHwith everyOPSCHAIN_VAULT_*name it finds), or the step's bound SecretsProvider connection projected into the job spec. Precedence is ATOMIC per provider, runner first: when the runner's allowlisted environment carries its ownOPSCHAIN_VAULT_ADDRESS, the runner's wholeOPSCHAIN_VAULT_*set wins and server-projected vault variables are dropped — never merged variable-by-variable (one vault's address must not pair with another's token); a runner with no vault of its own keeps the projected set. Server-sidesecret-vault://property-reference resolution is a separate moment with its own backend (the server'svault.type): references in documents are resolved by the server at spec-build time, whileSecretVaultcalls resolve on the runner at execution time — one job can need both. The vault token/password values are masked in logs by whichever side knows them. - Steps get a child token, never the runner's own: whichever lane supplies a token-auth provider, the runner mints
a step-scoped CHILD token from it at dispatch (OpenBao
auth/token/create: non-renewable, the parent's own policies, not an orphan — revoking the parent revokes its children, TTL = the step's timeout plus 30 minutes' slack, or a 4-hour ceiling when no timeout is known) honouring the provider'sOPSCHAIN_VAULT_SSL_VERIFY/ssl_ca_certoptions. The step'sOPSCHAIN_VAULT_TOKENcarries the child; every otherOPSCHAIN_VAULT_*variable passes through unchanged; the parent token never enters any step environment. The child is best-effort revoked (auth/token/revoke-self) when the step finishes — expiry is the backstop, and a failed revoke never fails the step. A vault that refuses child minting (restrictive token role, network error) degrades loudly, not silently: the step runs with the parent-token passthrough and its log opens with the named noticevault: step-scoped child token unavailable (...). The runner-held vault secret values (OPSCHAIN_VAULT_TOKEN,_PASSWORD,_SECRET_ID,_CLIENT_OPTIONS) always join the step's log mask set, so neither the parent nor the child token can print into captured output — on the child-token path and the fallback path alike. - The server-minted vault lease supersedes the projected credential
(runner protocol v6+): instead of
projecting the bound provider's credential into the spec env, the server
mints a response-wrapped, step-scoped child token per action step —
TTL-bounded like the 066 child, non-renewable, carrying run/step
metadata, and narrowed to the provider's KV mount via the provisioned
opschain-step-lease-<mount>policy/token role where the estate credential may provision (otherwise a parent-policy child, named as such). The spec'svault_leaseelement carries the single-use wrapping token; the runner unwraps it (sys/wrapping/unwrap— a spent or expired wrapping token is named in the step log as possible interception) and bridges the lease in asOPSCHAIN_VAULT_TOKENthrough the masked Secrets channel, revoking it at step end. The env-var lane above stays the deploy-authoritative override: a runner host carrying its ownOPSCHAIN_VAULT_ADDRESSgoverns wholesale and the lease is ignored with a named notice; a pre-v6 runner never receives a lease and keeps the credential projection; a refused mint degrades loudly server-side back to the credential projection. On the lease path the runner-side 066 child-mint is naturally inert (the spec env carries no parent token) — the lease IS the step-scoped token. TERMdefaults todumbunder both executors unless the spec sets one.- Secret values are injected as env vars and simultaneously registered on the run's masker, so they are masked out of logs and captured streams.
Log capture
Both executors stream the step's output through the run's masker to the log pipe as line chunks, through a TTY/PTY so line-buffering runtimes (ruby, python) emit live output; the pipeline strips the CR the line discipline adds. Under containerexec the TTY merges stdout and stderr into one stream.
A step opting in with capture: additionally buffers the stream(s) during
the run and folds them into its step_result.json outputs as the stdout /
stderr keys, so later steps can reference
${{ steps.<name>.outputs.stdout }}. Each captured stream is budgeted to
64 KiB, tail-keeping: an over-budget stream keeps its tail under the marker
[opschain: head truncated — the stream exceeded the 65536-byte capture budget, the tail is kept; the full stream is in the step log]. Captured
content is masked before it leaves the executor. Two named refusals:
capturing stderr under containerexec (capture: stderr is unavailable under containerexec — the step container runs under a TTY that merges stdout and stderr into one stream; capture stdout to consume the merged stream), and
a step publishing an output named stdout/stderr it also captures
(step output "<k>" collides with the step's capture).
Exit and failure semantics
- A non-zero exit is an outcome, not an error — it travels in the run
outcome's exit code. A hostexec process killed by a signal maps to
128 + signal. A container's exit status is reported as-is. - Cancellation — hostexec SIGTERMs the step's whole process group, then
SIGKILLs after the grace window (30 s default); containerexec issues the
runtime's stop with the same grace, and the runtime escalates to kill.
The outcome is marked
cancelled. - Timeout — a step timeout cancels the same way and marks the outcome
timed_outinstead. - Suspend-in-place (platform halt): hostexec SIGSTOPs/SIGCONTs the
process group; containerexec pauses/unpauses the container. The kill/stop
escalation waits out a suspension, so a timeout or cancel firing mid-halt
never destroys the frozen state. An executor that cannot suspend reports
suspend-in-place is not supported by this executorand the step runs to completion (recorded in the halt audit). - A missing
step_result.jsonis read as "the step failed before writing its result" — distinct from a malformed one, which fails contract validation loudly. Infrastructure failures before the step even runs (content fetch, workspace, image) submit as an execution error rather than an exit code. - Images are not pulled by default — local mode is offline-first; a
missing image fails with
image <ref> is not present locally and image pulling is disabled (WithImagePull): pull it ahead of time — local mode is offline-first.
Related pages
opschain docs runner— the process that drives these executors on a remote host, including suspend/drain directives.opschain docs cli/runner— the--executorsand--env-passthroughflags.opschain docs fleet-queue— how the executor demand folds into placement.opschain docs cli/doctor— reports the detected container runtime (or why none was found).