|
| 1 | +--- |
| 2 | +title: "55. Unified environment variable delivery for harness runner and sandbox" |
| 3 | +status: Accepted |
| 4 | +relates_to: |
| 5 | + - agent-architecture |
| 6 | + - agent-infrastructure |
| 7 | +topics: |
| 8 | + - harness |
| 9 | + - configuration |
| 10 | + - environment |
| 11 | +--- |
| 12 | + |
| 13 | +# 55. Unified environment variable delivery for harness runner and sandbox |
| 14 | + |
| 15 | +Date: 2026-06-23 |
| 16 | + |
| 17 | +Amends: [ADR 0024](0024-harness-definitions.md), [ADR 0049](0049-agent-configuration-env-var-convention.md) |
| 18 | + |
| 19 | +## Status |
| 20 | + |
| 21 | +Accepted |
| 22 | + |
| 23 | +## Context |
| 24 | + |
| 25 | +Setting an environment variable that needs to reach both the runner (pre/post |
| 26 | +scripts) and the sandbox (agent inference) requires specifying it in two |
| 27 | +independent mechanisms with different formats: |
| 28 | + |
| 29 | +1. `runner_env:` in the harness YAML — a key-value map for host-side scripts. |
| 30 | +2. A `.env` file under `env/` — shell `export` syntax, delivered via |
| 31 | + `host_files` with `expand: true`. |
| 32 | + |
| 33 | +ADR 0049 acknowledges this explicitly: "A config var needed by both must |
| 34 | +appear in both places." |
| 35 | + |
| 36 | +The `.env` file is especially painful to customize. It contains all |
| 37 | +passthrough context vars (`GITHUB_PR_URL`, `GH_TOKEN`, `PR_NUMBER`, etc.). |
| 38 | +Adding a single custom var like `REVIEW_FINDING_SEVERITY_THRESHOLD` forces |
| 39 | +forking the entire file and maintaining all those passthroughs — see |
| 40 | +[fullsend-ai/.fullsend#84](https://github.com/fullsend-ai/.fullsend/pull/84). |
| 41 | + |
| 42 | +This separation was not an intentional design choice. It fell out of the |
| 43 | +original `fullsend run` implementation (PR #231), which solved two different |
| 44 | +runtime problems at different execution points and was later codified into |
| 45 | +ADR 0024 without anyone asking whether a user should have to specify the same |
| 46 | +var in two places. |
| 47 | + |
| 48 | +## Decision |
| 49 | + |
| 50 | +Add a new `env:` top-level field to the harness schema with `runner` and |
| 51 | +`sandbox` sub-maps. Deprecate `runner_env` in favor of `env.runner`. |
| 52 | + |
| 53 | +`host_files` env delivery (`.env` files with `expand: true`) remains |
| 54 | +permanently supported alongside `env.sandbox`. The two mechanisms are |
| 55 | +complementary: `env.sandbox` is convenient for simple per-harness vars, |
| 56 | +while `host_files` provides file-level composability that `env.sandbox` |
| 57 | +cannot match (e.g. one `.env` file per tool, mix-and-matched across |
| 58 | +harnesses without duplication). |
| 59 | + |
| 60 | +### Schema |
| 61 | + |
| 62 | +```yaml |
| 63 | +env: |
| 64 | + runner: |
| 65 | + FULLSEND_OUTPUT_SCHEMA: "${FULLSEND_DIR}/schemas/review-result.schema.json" |
| 66 | + sandbox: |
| 67 | + GITHUB_PR_URL: "${GITHUB_PR_URL}" |
| 68 | + GH_TOKEN: "${GH_TOKEN}" |
| 69 | + REVIEW_FINDING_SEVERITY_THRESHOLD: "medium" |
| 70 | +``` |
| 71 | +
|
| 72 | +- `env.runner` — key-value pairs set in the host process environment for |
| 73 | + pre/post scripts and the validation loop. Replaces `runner_env`. |
| 74 | +- `env.sandbox` — key-value pairs the runner writes into a generated `.env` |
| 75 | + file and copies into the sandbox at bootstrap. Complements (does not |
| 76 | + replace) `.env` files delivered via `host_files`. |
| 77 | +- Values in both sub-maps support `${VAR}` expansion from the host |
| 78 | + environment, same as `runner_env` and `expand: true` host_files today. |
| 79 | + |
| 80 | +The `env:` field can appear at the top level and inside `forge.<platform>` |
| 81 | +blocks, replacing `runner_env` at both levels |
| 82 | +([ADR 0045](0045-forge-portable-harness-schema.md)). |
| 83 | + |
| 84 | +Go struct: |
| 85 | + |
| 86 | +```go |
| 87 | +type EnvConfig struct { |
| 88 | + Runner map[string]string `yaml:"runner,omitempty"` |
| 89 | + Sandbox map[string]string `yaml:"sandbox,omitempty"` |
| 90 | +} |
| 91 | +``` |
| 92 | + |
| 93 | +Added to both `Harness` and `ForgeConfig`: |
| 94 | + |
| 95 | +```go |
| 96 | +Env *EnvConfig `yaml:"env,omitempty"` |
| 97 | +``` |
| 98 | + |
| 99 | +### Merge semantics |
| 100 | + |
| 101 | +`env:` follows the same per-variable additive merge rules established by |
| 102 | +ADR 0045 for `runner_env`: |
| 103 | + |
| 104 | +- **`base:` composition** — parent map merged with child map; child keys win |
| 105 | + on collision. Each sub-map (`runner`, `sandbox`) merges independently. A |
| 106 | + child that declares only one sub-map inherits the other from the parent. |
| 107 | +- **`forge.<platform>` resolution** — identical rules. Forge sub-maps merge |
| 108 | + with top-level sub-maps; forge keys win. |
| 109 | + |
| 110 | +**Limitation:** merge is strictly additive — there is no mechanism for a |
| 111 | +child to remove a key inherited from its base. A child that inherits |
| 112 | +`GITHUB_ISSUE_URL` from a base cannot suppress it; it can only override |
| 113 | +the value. If removal semantics are needed in the future, a YAML `null` |
| 114 | +/ `~` sentinel could be added. |
| 115 | + |
| 116 | +### Runner behavior |
| 117 | + |
| 118 | +When `env.sandbox` is present (after all merges), the runner: |
| 119 | + |
| 120 | +1. Expands `${VAR}` references from the host environment using Go's |
| 121 | + `os.Expand`, which supports `$VAR` and `${VAR}` syntax only — no |
| 122 | + default values, substring operations, or other shell parameter |
| 123 | + expansion features. |
| 124 | +2. Writes the result as `KEY=value` lines to a generated `.env` file inside |
| 125 | + the sandbox (e.g. `/sandbox/workspace/.env.d/generated.env`). |
| 126 | +3. The sandbox's `envfile.Load` picks it up normally. |
| 127 | + |
| 128 | +`env.runner` sets key-value pairs in the host process environment before |
| 129 | +executing pre/post scripts and the validation loop — identical to current |
| 130 | +`runner_env` behavior. |
| 131 | + |
| 132 | +### Precedence |
| 133 | + |
| 134 | +When both `env.sandbox` and `host_files` `.env` entries define the same |
| 135 | +key, `env.sandbox` takes precedence. This is enforced by bootstrap |
| 136 | +ordering: `.env.d/` files are sourced first, then `env.sandbox` exports |
| 137 | +are emitted, so `env.sandbox` wins on collision. This matches the |
| 138 | +expected use case: a harness inherits a shared `.env` file via |
| 139 | +`host_files` and overrides a single var with `env.sandbox`. |
| 140 | + |
| 141 | +### Deprecation |
| 142 | + |
| 143 | +`runner_env` **always** emits a deprecation warning when present, regardless |
| 144 | +of whether `env:` also exists: |
| 145 | + |
| 146 | +- When `env:` is also present: `env.runner` wins; warning says so. |
| 147 | +- When `env:` is absent: `runner_env` still works; warning says |
| 148 | + "migrate to env.runner." |
| 149 | +- Same rules apply to `forge.<platform>.runner_env`. |
| 150 | + |
| 151 | +`host_files` env delivery is **not deprecated**. It provides file-level |
| 152 | +composability (one `.env` file per tool, mixed across harnesses) that |
| 153 | +`env.sandbox` cannot structurally replicate. The two mechanisms coexist |
| 154 | +permanently. |
| 155 | + |
| 156 | +### Migration phases |
| 157 | + |
| 158 | +**Phase 1 — Schema extension (this ADR):** Add `env:` to `Harness` and |
| 159 | +`ForgeConfig`. `runner_env` emits deprecation warnings whenever present. When |
| 160 | +both exist, `env.runner` wins. Runner generates `.env` from `env.sandbox`. |
| 161 | + |
| 162 | +**Phase 2 — Migrate scaffold harnesses:** Update all scaffold harnesses to |
| 163 | +use `env:` instead of `runner_env`. Move simple passthrough vars from manual |
| 164 | +`.env` files into `env.sandbox` where appropriate. Harnesses that use |
| 165 | +modular per-tool `.env` files via `host_files` keep them. |
| 166 | + |
| 167 | +**Phase 3 — Remove `runner_env`:** Remove `runner_env` from the Go structs. |
| 168 | +`yaml.Unmarshal` silently ignores it in old files. `Lint()` emits an error |
| 169 | +for harnesses that still reference it. |
| 170 | + |
| 171 | +## Consequences |
| 172 | + |
| 173 | +- Adding a config var that both runner and sandbox need is a change to one |
| 174 | + file (the harness YAML), not a fork of an entire `.env` file. |
| 175 | +- `base:` composition works naturally — adding one config knob to a |
| 176 | + customized harness is a few lines, not a full env file fork. |
| 177 | +- No runner changes are needed for Phase 1 beyond generating the `.env` file |
| 178 | + from `env.sandbox` and emitting deprecation warnings for `runner_env`. |
| 179 | +- Existing harnesses continue to work unchanged; they just get noisier about |
| 180 | + `runner_env` deprecation. |
| 181 | +- ADR 0049's env var naming convention applies unchanged — the delivery |
| 182 | + mechanism changes but the `{AGENT}_{SETTING_NAME}` convention does not. |
| 183 | +- Modular `.env` files via `host_files` remain the right choice for |
| 184 | + per-tool env groups shared across multiple harnesses. |
| 185 | +- This change extends the harness schema; runners older than Phase 1 will |
| 186 | + silently ignore `env:` and fall back to `runner_env` / `host_files` only. |
| 187 | + Harness schema versioning ([#235](https://github.com/fullsend-ai/fullsend/issues/235)) |
| 188 | + would make this evolution explicit. |
| 189 | +- Env merge is strictly additive. A child cannot remove a key inherited from |
| 190 | + its base — it can only override the value. |
0 commit comments