Skip to main content
Version: v5.6.1

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:

KindRuns the step as
hostexecA 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.
containerexecOne 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): hostexec always, plus containerexec when a container runtime is detected at startup.
  • An explicit list (hostexec,containerexec): naming containerexec with 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 with unknown 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 --local mode it is a linked git worktree of the source repository: git describe resolves the pinned commit and refs written inside (e.g. a release git tag your action creates) land in the real repository, while the engine's traffic files stay out of git status only 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); see opschain 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 sibling step_context.version, and the step_result.json the 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's OpsChain.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 the warn:mintmodel:actions_unavailable event; updating the runner removes that budget.
  • .opschain/packs/<name> — each action pack the step's compiled graph references, as read-only inputs; $OPSCHAIN_PACKS_DIR names 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, LOGNAME and the proxy variables (HTTP_PROXY/HTTPS_PROXY/NO_PROXY, upper and lower case). The runner's --env-passthrough / OPSCHAIN_RUNNER_ENV_PASSTHROUGH extends this allowlist — the runner-in-container contract, for toolchain configuration every step needs. PATH is 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 an opschain symlink to the running engine binary) after them — so opschain step subcommands 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. PATH is 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 (the opschain step subcommands write to these).
  • Every server-dispatched step gets OPSCHAIN_STEP_TOKEN and OPSCHAIN_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 kind step), and a change or workflow run it creates joins the invoking run's correlation chain as a step-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 /api bearer seam (the bearer vocabulary gained the ocst_ credential class, owed a v2 functional equivalent before any v1 sunset).
  • A step whose command references OPSCHAIN_LICENCE_DOCUMENT gets 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 grants tools:rpcu, packs:rpc or packs:infrastructure. Pack action wrappers hand it to tools that verify LimePoint licences offline (their --licence-env channel). 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 *.licence file under $OPSCHAIN_HOME/licences is one applied document (re-verified on every read), and those files also satisfy the pack entitlement gate when a bundle:-mounted pack requires one.
  • A step whose command references OPSCHAIN_TRANSPORT_SESSION gets 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 a code, a kind (ssh-static, the default, or ssh-ca), a credential that must be a secret-vault:// reference into the local secret store, an optional auth_method (key, the default, or password) for ssh-static, and username/principals/validity_seconds for ssh-ca. The deepest rpc.provider_ref property 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 the SecretVault that 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 extends OPSCHAIN_RUNNER_ENV_PASSTHROUGH with every OPSCHAIN_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 own OPSCHAIN_VAULT_ADDRESS, the runner's whole OPSCHAIN_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-side secret-vault:// property-reference resolution is a separate moment with its own backend (the server's vault.type): references in documents are resolved by the server at spec-build time, while SecretVault calls 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's OPSCHAIN_VAULT_SSL_VERIFY / ssl_ca_cert options. The step's OPSCHAIN_VAULT_TOKEN carries the child; every other OPSCHAIN_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 notice vault: 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's vault_lease element 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 as OPSCHAIN_VAULT_TOKEN through the masked Secrets channel, revoking it at step end. The env-var lane above stays the deploy-authoritative override: a runner host carrying its own OPSCHAIN_VAULT_ADDRESS governs 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.
  • TERM defaults to dumb under 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_out instead.
  • 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 executor and the step runs to completion (recorded in the halt audit).
  • A missing step_result.json is 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.
  • opschain docs runner — the process that drives these executors on a remote host, including suspend/drain directives.
  • opschain docs cli/runner — the --executors and --env-passthrough flags.
  • 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).