product: assert audience: test-developer authority: normative
Reference — Runner environment
The environment variables a test step can rely on inside a runner container, and the one that looks like it belongs here and does not.
| Variable | Value on a station | Read by | What it is for |
|---|---|---|---|
ARTIFACT_DIR |
/data/artifacts |
both runners | Where a step puts files it wants attached to the report (directly, or through Python's emit_artifact) |
PACKAGES_ROOT |
/data/tat-packages |
both runners | Root of the installed packages, mounted read-only |
Both are set explicitly for the python-runner and dotnet-runner services in
docker-compose.station.yml, and both runners fall back to the same defaults if a variable is
missing.
ARTIFACT_DIR
The artifact staging directory. A step attaches a file to the execution report by putting it
here. The executor empties the directory before every step; when the step returns (whatever its
verdict), the runner enumerates the directory and reports what it finds, and the executor
reconciles that list against the shared staging volume and uploads each file against the step
that produced it. Only files directly in the directory are collected, not subdirectories, and a
file larger than Artifacts:MaxFileSizeBytes (10 MB by default) is skipped with a warning.
The API does not read ARTIFACT_DIR: its side of the shared artifact-data volume is
Artifacts:StagingRoot, default /data/artifacts, and the two must name the same mount.
A Python step can call emit_artifact(path, description, content_type=None) or
emit_plotly(fig, description) instead - runner builtins, no import - which copy the file here
and attach a description and content type, which a bare file cannot carry. From Python
runner 1.2.0 (Assert 2.3.0); see sdk/python-sdk.md. Writing the file yourself works on every
version, in both runners, and outside a station:
import os
ARTIFACT_DIR = os.environ.get("ARTIFACT_DIR", "/data/artifacts")
static readonly string ArtifactDir =
Environment.GetEnvironmentVariable("ARTIFACT_DIR") ?? "/data/artifacts";
Always read the variable with that fallback rather than hard-coding /data/artifacts. The
variable is what keeps the module correct if a deployment moves the volume; the fallback is
what keeps it runnable outside a station, in a unit test or on a developer laptop.
mkdir before writing. The directory exists on a running station because the volume is
mounted, but os.makedirs(ARTIFACT_DIR, exist_ok=True) (or Directory.CreateDirectory) costs
nothing and is what makes the same module work off-station.
The file name decides how the artifact is displayed — unless the step declared a content
type through emit_artifact/emit_plotly, the reconcile path infers it from the extension
alone. *.png and *.svg render inline, *.csv renders as a table, and a Plotly figure must be
named *.plotly.json (the double extension is matched, a plain .json is not). Full table in
sdk/python-sdk.md and sdk/dotnet-sdk.md.
PACKAGES_ROOT
The root of the installed package cache, mounted read-only into both runners. The Python runner
re-scans it before every import and adds each <package>/python_modules directory to
sys.path, so a package installed after the runner started is picked up without a restart.
A step therefore imports a module by its bare name — module: "generate_csv_artifact", not a
path — and the module must sit directly in that package's python_modules/ directory.
ALLOW_ONLINE_PIP
Lets the Python runner fall back to PyPI when a package declares pipPackages for which it has no
vendored wheels. Development stations only: a production station should ship its wheels, and a
station that reaches PyPI at test time can have its dependencies change underneath it between runs.
Two things control it, and they are not redundant:
| Set in | When it applies | Wins |
|---|---|---|
ALLOW_ONLINE_PIP (environment, from .env) |
Runner startup, and any step where the config key is unset | The default |
AllowOnlinePip (station config) |
From the first step of a test onwards | Overrides the environment, in both directions |
The station config key is authoritative because it can be changed on a running station without
touching the deployment, and an explicit false there turns PyPI fallback off even when .env
asked for it. But it cannot be read until a step executes and the API injects it as
cfg.AllowOnlinePip, and a freshly installed station has no way to set it before someone opens the
UI - which is what the environment variable is for.
Both paths resolve through resolve_allow_online_pip() in dependency_installer.py, so the
precedence is defined once.
Regression, 2026-05-13 to 2026-09-17. For four months nothing read
ALLOW_ONLINE_PIPwhile.env.exampleand all three compose files went on setting it. A station that enabled it installed nothing from PyPI and reported no reason. Fixed;test_allow_online_pip.pypins the precedence in both directions.
What is not an environment variable
| Thing | Where it actually comes from |
|---|---|
Scripts root (/app/scripts) |
The runner's command line, argument 3 |
| Runner port and identifier | The runner's command line, arguments 1 and 2 |
| Station name, Orchestra URL, ingest key | The API's environment, not the runners' |
AllowOnlinePip (the station config key) |
Station configuration, delivered in step variables. Distinct from the ALLOW_ONLINE_PIP env var above, which it overrides. |