55. Unified environment variable delivery for harness runner and sandbox
Date: 2026-06-23
Status
Accepted
Context
Setting an environment variable that needs to reach both the runner (pre/post scripts) and the sandbox (agent inference) requires specifying it in two independent mechanisms with different formats:
runner_env:in the harness YAML — a key-value map for host-side scripts.- A
.envfile underenv/— shellexportsyntax, delivered viahost_fileswithexpand: true.
ADR 0049 acknowledges this explicitly: "A config var needed by both must appear in both places."
The .env file is especially painful to customize. It contains all passthrough context vars (GITHUB_PR_URL, GH_TOKEN, PR_NUMBER, etc.). Adding a single custom var like REVIEW_FINDING_SEVERITY_THRESHOLD forces forking the entire file and maintaining all those passthroughs — see fullsend-ai/.fullsend#84.
This separation was not an intentional design choice. It fell out of the original fullsend run implementation (PR #231), which solved two different runtime problems at different execution points and was later codified into ADR 0024 without anyone asking whether a user should have to specify the same var in two places.
Decision
Add a new env: top-level field to the harness schema with runner and sandbox sub-maps. Deprecate runner_env in favor of env.runner.
host_files env delivery (.env files with expand: true) remains permanently supported alongside env.sandbox. The two mechanisms are complementary: env.sandbox is convenient for simple per-harness vars, while host_files provides file-level composability that env.sandbox cannot match (e.g. one .env file per tool, mix-and-matched across harnesses without duplication).
Schema
env:
runner:
FULLSEND_OUTPUT_SCHEMA: "${FULLSEND_DIR}/schemas/review-result.schema.json"
sandbox:
GITHUB_PR_URL: "${GITHUB_PR_URL}"
GH_TOKEN: "${GH_TOKEN}"
REVIEW_FINDING_SEVERITY_THRESHOLD: "medium"env.runner— key-value pairs set in the host process environment for pre/post scripts and the validation loop. Replacesrunner_env.env.sandbox— key-value pairs the runner writes into a generated.envfile and copies into the sandbox at bootstrap. Complements (does not replace).envfiles delivered viahost_files.- Values in both sub-maps support
${VAR}expansion from the host environment, same asrunner_envandexpand: truehost_files today.
The env: field can appear at the top level and inside forge.<platform> blocks, replacing runner_env at both levels (ADR 0045).
Go struct:
type EnvConfig struct {
Runner map[string]string `yaml:"runner,omitempty"`
Sandbox map[string]string `yaml:"sandbox,omitempty"`
}Added to both Harness and ForgeConfig:
Env *EnvConfig `yaml:"env,omitempty"`Merge semantics
env: follows the same per-variable additive merge rules established by ADR 0045 for runner_env:
base:composition — parent map merged with child map; child keys win on collision. Each sub-map (runner,sandbox) merges independently. A child that declares only one sub-map inherits the other from the parent.forge.<platform>resolution — identical rules. Forge sub-maps merge with top-level sub-maps; forge keys win.
Limitation: merge is strictly additive — there is no mechanism for a child to remove a key inherited from its base. A child that inherits GITHUB_ISSUE_URL from a base cannot suppress it; it can only override the value. If removal semantics are needed in the future, a YAML null / ~ sentinel could be added.
Runner behavior
When env.sandbox is present (after all merges), the runner:
- Expands
${VAR}references from the host environment using Go'sos.Expand, which supports$VARand${VAR}syntax only — no default values, substring operations, or other shell parameter expansion features. - Writes the result as
KEY=valuelines to a generated.envfile inside the sandbox (e.g./sandbox/workspace/.env.d/generated.env). - The sandbox's
envfile.Loadpicks it up normally.
env.runner sets key-value pairs in the host process environment before executing pre/post scripts and the validation loop — identical to current runner_env behavior.
Precedence
When both env.sandbox and host_files .env entries define the same key, env.sandbox takes precedence. This is enforced by bootstrap ordering: .env.d/ files are sourced first, then env.sandbox exports are emitted, so env.sandbox wins on collision. This matches the expected use case: a harness inherits a shared .env file via host_files and overrides a single var with env.sandbox.
Deprecation
runner_env always emits a deprecation warning when present, regardless of whether env: also exists:
- When
env:is also present:env.runnerwins; warning says so. - When
env:is absent:runner_envstill works; warning says "migrate to env.runner." - Same rules apply to
forge.<platform>.runner_env.
host_files env delivery is not deprecated. It provides file-level composability (one .env file per tool, mixed across harnesses) that env.sandbox cannot structurally replicate. The two mechanisms coexist permanently.
Migration phases
Phase 1 — Schema extension (this ADR): Add env: to Harness and ForgeConfig. runner_env emits deprecation warnings whenever present. When both exist, env.runner wins. Runner generates .env from env.sandbox.
Phase 2 — Migrate scaffold harnesses: Update all scaffold harnesses to use env: instead of runner_env. Move simple passthrough vars from manual .env files into env.sandbox where appropriate. Harnesses that use modular per-tool .env files via host_files keep them.
Phase 3 — Remove runner_env: Remove runner_env from the Go structs. yaml.Unmarshal silently ignores it in old files. Lint() emits an error for harnesses that still reference it.
Consequences
- Adding a config var that both runner and sandbox need is a change to one file (the harness YAML), not a fork of an entire
.envfile. base:composition works naturally — adding one config knob to a customized harness is a few lines, not a full env file fork.- No runner changes are needed for Phase 1 beyond generating the
.envfile fromenv.sandboxand emitting deprecation warnings forrunner_env. - Existing harnesses continue to work unchanged; they just get noisier about
runner_envdeprecation. - ADR 0049's env var naming convention applies unchanged — the delivery mechanism changes but the
{AGENT}_{SETTING_NAME}convention does not. - Modular
.envfiles viahost_filesremain the right choice for per-tool env groups shared across multiple harnesses. - This change extends the harness schema; runners older than Phase 1 will silently ignore
env:and fall back torunner_env/host_filesonly. Harness schema versioning (#235) would make this evolution explicit. - Env merge is strictly additive. A child cannot remove a key inherited from its base — it can only override the value.
