Skip to main content
Version: v5.7.0

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. A local run extends it the same way with --env-passthrough on opschain run / runs create … --local (the CI pattern: CI, GITHUB_SHA, a toolchain's variables); in both lanes OPSCHAIN_VAULT_* names are dropped, and a variable not named never reaches a step. 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 step of an action with declared inputs: gets one OPSCHAIN_INPUT_<NAME> per input (the name upper-cased, every other character _) holding the raw resolved value — exactly what ${{ inputs.<name> }} would splice, defaults applied, with: expressions resolved — and one OPSCHAIN_INPUT_FILE_<NAME> naming a file under inputs/ in the step's data-passing directory with the same bytes. A value over 32 KiB travels through its file alone (the value variable is unset: Linux caps one environment string at 128 KiB and macOS the whole environment at 1 MiB). A secret input's resolved value rides the secret-injection path, so it is masked like any secret. Both lanes stage the files identically; under containerexec the file variable names the workspace-mount path. See opschain docs orchestration ("Consuming inputs in run:").

  • 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: the step's bound server-resolved SecretsProvider connection projected into the job spec, or — when the spec carries no vault address — the runner's own secrets_providers configuration (below), projected for a hostexec step: the vault-kind provider the step's deferred references name, else the only vault-kind entry configured (several candidates project nothing and say so in the step log). Never merged variable-by-variable (one vault's address must not pair with another's token). The OPSCHAIN_VAULT_* runner-host environment lane is retired: those names are dropped from the env passthrough, and a runner started with them and no secrets_providers section refuses by name until opschain runner migrate-vault-env has written the section from them, once. Server-side secret-vault:// property-reference resolution is a separate moment: references routed to a server provider are resolved by the server at spec-build time, references routed to a runner provider are resolved by the runner at Prepare (next bullet), while SecretVault calls resolve on the runner at execution time — one job can need all three. The vault token/password values are masked in logs by whichever side knows them.

  • The runner resolves runner-provider references at Prepare (runner protocol v8+). The runner's config file, <runner dir>/config.yaml, carries a secrets_providers: section — one entry per provider code the estate registered with resolution: runner, owned by the customer and never stored in or transmitted by the server:

    secrets_providers:
    cust-vault:
    kind: openbao # openbao | hashicorp-vault | aws-secrets-manager | azure-key-vault
    address: https://bao.customer.example:8200
    mount: secrets # KV mount (vault kinds) / secret-name prefix (cloud kinds)
    kv_version: 2
    auth:
    method: approle # token | userpass | approle | access-key | client-secret
    role_id: 9f2c…
    credential_env: CUST_VAULT_SECRET_ID # or credential: <inline> / credential_file: <path>
    ssl_verify: true
    ca: /etc/ssl/cust-ca.pem
    aws-prod:
    kind: aws-secrets-manager
    region: ap-southeast-2
    auth: { method: access-key, username: AKIA…, credential_env: AWS_SECRET }

    The entry's codes are the capability the runner declares at registration and re-advertises on every poll; a misconfigured entry (unknown kind, missing address or region, no credential, two material sources) refuses the start naming the code. The spec's additive secret_refs element lists, per deferred reference, the provider code, the bare secret-vault://<path>/<key> and its target — context:<json pointer> into step_context.json, secret:<ENV> for a bound secret input's variable, or transport:OPSCHAIN_TRANSPORT_SESSION for a session bundle whose credential checks out at the runner. Before anything else touches the step the runner reads every entry through the configured provider (one read per provider), swaps the values into their targets, registers them on the mask set, and opens the step log with secrets: provider "cust-vault" - N reference(s) resolved at the runner. An unresolvable reference is a named infrastructure error — the reference and the provider code to check — never an empty value and never the literal reference handed to the step. A spec whose references name a provider the runner has not declared, or a pre-v8 session, is refused by name at offer time; placement keeps such work off the runner in the first place.

  • 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+), and it is run-scoped (protocol v9+): instead of projecting the bound provider's credential into the spec env, the server mints a response-wrapped child token once per run, runner and provider — TTL bounded to the run's timeout plus slack, non-renewable, carrying the run and runner identity, 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 on that runner's first step of the run; the runner unwraps it (sys/wrapping/unwrap — a spent or expired wrapping token is named in the step log as possible interception), holds it for the run and bridges it in as OPSCHAIN_VAULT_TOKEN through the masked Secrets channel. Later steps of the run on the same runner arrive with vault_lease.held and no wrapping token: the runner answers them from the token it holds (a held marker for a run it holds no token for — a restart — is loud and the step runs without a vault credential; the server sees the new session and mints afresh on the next step). A different runner gets its own mint; a restarted runner's old lease is revoked by accessor; at run end (run_finished on the result) the runner revokes what it holds and the server revokes every lease of the run by accessor — expiry the backstop. A pre-v9 session keeps the per-step mint, a pre-v6 session the credential projection; a refused mint degrades loudly server-side back to the credential projection; a runner-resolved provider mints no lease at all (the server holds nothing to mint from) — the runner narrows its own configured credential to a child token instead, once per run too (the child is held for the run and revoked at its end). On the lease path the runner-side child-mint is naturally inert (the spec env carries no parent token) — the lease IS the run-scoped token.

  • The runner inherits the vault traffic economy: references deferred to a runner provider resolve through the runner's own run snapshot — once per run per runner — re-read when the epoch the spec carries on a secret_refs entry moved (another runner's declared secrets_written) or when the step says refresh_secrets, metered through the provider's rate budget the spec ships (secret_economy: reads per second, mints per minute) with a circuit breaker on sustained refusal, and reported up the step's result as counts (secrets_usage) the server writes to the provider usage substrate and the run's evidence line. The runner's step log says secrets: provider "<code>" - N reference(s) resolved at the runner (M from the run snapshot) and, where it waited, waiting for vault budget (provider "<code>").

  • 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. The server masks a second time at ingest with its own copy of the run's mask set, which lives in the server's memory only for the run's lifetime: a step's in-memory state is released when its log seals, and the run's masker when the run finalises — a chunk that still arrives for the run is masked through a masker rebuilt from the mask set sealed onto the job record, and a chunk for a sealed step is refused as sealed and marked in the log, never spooled again.

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 ci — what the host executor's allowlist and the offline-first image rule mean for a CI job on the local lane.
  • 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).