Skip to content

Commit 79d3840

Browse files
authored
Merge pull request #2582 from fullsend-ai/feat-unified-env-delivery
feat(harness): unified env var delivery (ADR 0055)
2 parents cfb2a5d + 1c7dfef commit 79d3840

16 files changed

Lines changed: 1825 additions & 11 deletions

docs/ADRs/0024-harness-definitions.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ Date: 2026-04-07
2020

2121
Accepted
2222

23+
*Amended by [ADR 0055](0055-unified-env-var-delivery.md), which introduces a
24+
unified `env:` key with `runner`/`sandbox` sub-maps and deprecates `runner_env`
25+
and the manual `.env` file convention.*
26+
2327
## Context
2428

2529
Each agent invocation requires configuration that ties together several moving

docs/ADRs/0049-agent-configuration-env-var-convention.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,12 @@ documentation: config vars are behavioral knobs listed in
7171
### Where config vars live in the harness
7272

7373
Config vars are carried the same way as other agent env vars — no new schema
74-
fields are needed. The `.env` file and `runner_env` serve different
74+
fields are needed. *Note: [ADR 0055](0055-unified-env-var-delivery.md)
75+
introduces a unified `env:` key with `runner`/`sandbox` sub-maps that
76+
replaces `runner_env` and manual `.env` files. The delivery mechanism below
77+
still works but is deprecated in favor of `env.runner` and `env.sandbox`.*
78+
79+
The `.env` file and `runner_env` serve different
7580
audiences: the `.env` file delivers vars into the sandbox for the agent at
7681
inference time, while `runner_env` makes vars available to pre/post scripts
7782
on the host. A config var needed by both must appear in both places.
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
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.

docs/architecture.md

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -92,10 +92,15 @@ The harness draws its configuration from the adopting organization's **`.fullsen
9292
runner_env) from platform-neutral fields. Forge blocks inherit from
9393
top-level defaults and override only deltas
9494
([ADR 0045](ADRs/0045-forge-portable-harness-schema.md)).
95+
- Unified env var delivery: a single `env:` key with `runner` and `sandbox`
96+
sub-maps replaces `runner_env` and manual `.env` files. The runner generates
97+
the sandbox `.env` file from `env.sandbox` at bootstrap. `runner_env` is
98+
deprecated ([ADR 0055](ADRs/0055-unified-env-var-delivery.md), amending
99+
[ADR 0024](ADRs/0024-harness-definitions.md)).
95100
- Agent configuration env vars: behavioral knobs use `{AGENT}_{SETTING_NAME}`
96-
naming (e.g., `REVIEW_SEVERITY_THRESHOLD`), delivered via existing env var
97-
mechanisms (`.env` files, `runner_env`). Each agent documents its config
98-
vars in `docs/agents/<agent>.md`
101+
naming (e.g., `REVIEW_SEVERITY_THRESHOLD`), delivered via `env.runner` and
102+
`env.sandbox` in the harness YAML. Each agent documents its config vars in
103+
`docs/agents/<agent>.md`
99104
([ADR 0049](ADRs/0049-agent-configuration-env-var-convention.md)).
100105
- Agent-driven branch targeting: the code agent writes its chosen target
101106
branch to structured output. The post-script validates the choice against

0 commit comments

Comments
 (0)