Unified Env Var Delivery Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Add env: field with runner/sandbox sub-maps to the harness schema, deprecating runner_env and manual .env files per ADR 0055.
Architecture: Add EnvConfig struct to the harness package. Wire it into forge resolution, base composition, and validation. Add Lint() deprecation diagnostics for runner_env. Update the runner to expand and apply env.runner and generate sandbox .env files from env.sandbox. Emit deprecation warnings at runtime when runner_env is present.
Tech Stack: Go, YAML (gopkg.in/yaml.v3), existing harness/envfile packages
Task 1: Add EnvConfig struct and wire into Harness / ForgeConfig
Files:
Modify:
internal/harness/harness.go:195-224(Harness struct)Modify:
internal/harness/forge.go:9-20(ForgeConfig struct)Test:
internal/harness/harness_test.go[ ] Step 1: Write failing test for EnvConfig parsing
In internal/harness/harness_test.go, add:
func TestEnvConfig_ParsesFromYAML(t *testing.T) {
yaml := `
agent: agents/test.md
role: test
env:
runner:
FOO: bar
sandbox:
BAZ: qux
`
h, err := parseRaw([]byte(yaml))
require.NoError(t, err)
require.NotNil(t, h.Env)
assert.Equal(t, map[string]string{"FOO": "bar"}, h.Env.Runner)
assert.Equal(t, map[string]string{"BAZ": "qux"}, h.Env.Sandbox)
}- [ ] Step 2: Run test to verify it fails
Run: cd /home/rbean/code/fullsend-0 && go test ./internal/harness/ -run TestEnvConfig_ParsesFromYAML -v Expected: FAIL — h.Env is nil because the struct field doesn't exist yet.
- [ ] Step 3: Add EnvConfig struct and field to Harness
In internal/harness/harness.go, add the struct before the Harness type:
// EnvConfig holds environment variable maps for runner and sandbox targets.
// Replaces runner_env (ADR 0055). Values support ${VAR} expansion from the
// host environment.
type EnvConfig struct {
Runner map[string]string `yaml:"runner,omitempty"`
Sandbox map[string]string `yaml:"sandbox,omitempty"`
}Add the field to the Harness struct, after RunnerEnv:
Env *EnvConfig `yaml:"env,omitempty"`- [ ] Step 4: Run test to verify it passes
Run: cd /home/rbean/code/fullsend-0 && go test ./internal/harness/ -run TestEnvConfig_ParsesFromYAML -v Expected: PASS
- [ ] Step 5: Write failing test for EnvConfig in ForgeConfig
In internal/harness/forge_test.go, add:
func TestForgeConfig_EnvParsesFromYAML(t *testing.T) {
yaml := `
agent: agents/test.md
role: test
forge:
github:
env:
runner:
GH_TOKEN: "${GH_TOKEN}"
sandbox:
GITHUB_PR_URL: "${GITHUB_PR_URL}"
`
h, err := parseRaw([]byte(yaml))
require.NoError(t, err)
require.NotNil(t, h.Forge["github"])
require.NotNil(t, h.Forge["github"].Env)
assert.Equal(t, map[string]string{"GH_TOKEN": "${GH_TOKEN}"}, h.Forge["github"].Env.Runner)
assert.Equal(t, map[string]string{"GITHUB_PR_URL": "${GITHUB_PR_URL}"}, h.Forge["github"].Env.Sandbox)
}- [ ] Step 6: Run test to verify it fails
Run: cd /home/rbean/code/fullsend-0 && go test ./internal/harness/ -run TestForgeConfig_EnvParsesFromYAML -v Expected: FAIL — ForgeConfig has no Env field.
- [ ] Step 7: Add Env field to ForgeConfig
In internal/harness/forge.go, add to ForgeConfig:
Env *EnvConfig `yaml:"env,omitempty"`- [ ] Step 8: Run test to verify it passes
Run: cd /home/rbean/code/fullsend-0 && go test ./internal/harness/ -run TestForgeConfig_EnvParsesFromYAML -v Expected: PASS
- [ ] Step 9: Run full harness test suite
Run: cd /home/rbean/code/fullsend-0 && go test ./internal/harness/ -v Expected: All tests pass. No existing tests should break.
- [ ] Step 10: Commit
git add internal/harness/harness.go internal/harness/forge.go internal/harness/harness_test.go internal/harness/forge_test.go
git commit -S -s -m "$(cat <<'EOF'
feat(harness): add EnvConfig struct with runner/sandbox sub-maps
Add the env: field to Harness and ForgeConfig per ADR 0055. This is the
schema-only change — merge, resolution, and runtime behavior follow in
subsequent commits.
Assisted-by: Claude Opus 4.6 <noreply@anthropic.com>
EOF
)"Task 2: Wire env: into forge resolution (mergeForgeConfig)
Files:
Modify:
internal/harness/forge.go:112-136(mergeForgeConfig)Test:
internal/harness/forge_test.go[ ] Step 1: Write failing test for env merge in forge resolution
In internal/harness/forge_test.go, add:
func TestResolveForge_MergesEnv(t *testing.T) {
h := &Harness{
Agent: "agents/test.md",
Role: "test",
Env: &EnvConfig{
Runner: map[string]string{"SHARED": "base"},
Sandbox: map[string]string{"SHARED_SB": "base"},
},
Forge: map[string]*ForgeConfig{
"github": {
Env: &EnvConfig{
Runner: map[string]string{"GH_TOKEN": "tok"},
Sandbox: map[string]string{"PR_URL": "url"},
},
},
},
}
require.NoError(t, h.ResolveForge("github"))
require.NotNil(t, h.Env)
assert.Equal(t, "base", h.Env.Runner["SHARED"])
assert.Equal(t, "tok", h.Env.Runner["GH_TOKEN"])
assert.Equal(t, "base", h.Env.Sandbox["SHARED_SB"])
assert.Equal(t, "url", h.Env.Sandbox["PR_URL"])
}
func TestResolveForge_EnvForgeOverridesTopLevel(t *testing.T) {
h := &Harness{
Agent: "agents/test.md",
Role: "test",
Env: &EnvConfig{
Runner: map[string]string{"KEY": "top"},
},
Forge: map[string]*ForgeConfig{
"github": {
Env: &EnvConfig{
Runner: map[string]string{"KEY": "forge"},
},
},
},
}
require.NoError(t, h.ResolveForge("github"))
assert.Equal(t, "forge", h.Env.Runner["KEY"])
}
func TestResolveForge_EnvInheritedWhenForgeNil(t *testing.T) {
h := &Harness{
Agent: "agents/test.md",
Role: "test",
Env: &EnvConfig{
Runner: map[string]string{"INHERITED": "yes"},
Sandbox: map[string]string{"ALSO": "inherited"},
},
Forge: map[string]*ForgeConfig{
"github": {},
},
}
require.NoError(t, h.ResolveForge("github"))
require.NotNil(t, h.Env)
assert.Equal(t, "yes", h.Env.Runner["INHERITED"])
assert.Equal(t, "inherited", h.Env.Sandbox["ALSO"])
}- [ ] Step 2: Run tests to verify they fail
Run: cd /home/rbean/code/fullsend-0 && go test ./internal/harness/ -run TestResolveForge_.*Env -v Expected: FAIL — mergeForgeConfig doesn't handle Env.
- [ ] Step 3: Add env merge logic to
mergeForgeConfig
In internal/harness/forge.go, add to mergeForgeConfig after the ValidationLoop block:
// Env: merge sub-maps independently; forge keys win (ADR 0055)
if fc.Env != nil {
if h.Env == nil {
h.Env = &EnvConfig{}
}
if fc.Env.Runner != nil {
if h.Env.Runner == nil {
h.Env.Runner = make(map[string]string, len(fc.Env.Runner))
}
for k, v := range fc.Env.Runner {
h.Env.Runner[k] = v
}
}
if fc.Env.Sandbox != nil {
if h.Env.Sandbox == nil {
h.Env.Sandbox = make(map[string]string, len(fc.Env.Sandbox))
}
for k, v := range fc.Env.Sandbox {
h.Env.Sandbox[k] = v
}
}
}- [ ] Step 4: Run tests to verify they pass
Run: cd /home/rbean/code/fullsend-0 && go test ./internal/harness/ -run TestResolveForge_.*Env -v Expected: PASS
- [ ] Step 5: Run full harness test suite
Run: cd /home/rbean/code/fullsend-0 && go test ./internal/harness/ -v Expected: All tests pass.
- [ ] Step 6: Commit
git add internal/harness/forge.go internal/harness/forge_test.go
git commit -S -s -m "$(cat <<'EOF'
feat(harness): merge env: in forge resolution
Wire EnvConfig into mergeForgeConfig so forge.<platform>.env sub-maps
merge with top-level env following the same per-variable additive merge
semantics as runner_env (ADR 0045).
Assisted-by: Claude Opus 4.6 <noreply@anthropic.com>
EOF
)"Task 3: Wire env: into base composition (mergeBaseIntoChild)
Files:
Modify:
internal/harness/compose.go:372-477(mergeBaseIntoChild)Modify:
internal/harness/compose.go:827-864(mergeForgeConfigInto)Test:
internal/harness/compose_test.go[ ] Step 1: Write failing test for env merge in base composition
In internal/harness/compose_test.go, add:
func TestMergeBaseIntoChild_Env(t *testing.T) {
base := &Harness{
Env: &EnvConfig{
Runner: map[string]string{"BASE_R": "r1"},
Sandbox: map[string]string{"BASE_S": "s1"},
},
}
child := &Harness{
Env: &EnvConfig{
Sandbox: map[string]string{"CHILD_S": "s2"},
},
}
mergeBaseIntoChild(base, child)
require.NotNil(t, child.Env)
assert.Equal(t, "r1", child.Env.Runner["BASE_R"])
assert.Equal(t, "s1", child.Env.Sandbox["BASE_S"])
assert.Equal(t, "s2", child.Env.Sandbox["CHILD_S"])
}
func TestMergeBaseIntoChild_EnvChildWins(t *testing.T) {
base := &Harness{
Env: &EnvConfig{
Runner: map[string]string{"KEY": "base"},
},
}
child := &Harness{
Env: &EnvConfig{
Runner: map[string]string{"KEY": "child"},
},
}
mergeBaseIntoChild(base, child)
assert.Equal(t, "child", child.Env.Runner["KEY"])
}
func TestMergeBaseIntoChild_EnvInheritedWhenChildNil(t *testing.T) {
base := &Harness{
Env: &EnvConfig{
Runner: map[string]string{"R": "val"},
Sandbox: map[string]string{"S": "val"},
},
}
child := &Harness{}
mergeBaseIntoChild(base, child)
require.NotNil(t, child.Env)
assert.Equal(t, "val", child.Env.Runner["R"])
assert.Equal(t, "val", child.Env.Sandbox["S"])
}- [ ] Step 2: Run tests to verify they fail
Run: cd /home/rbean/code/fullsend-0 && go test ./internal/harness/ -run TestMergeBaseIntoChild_Env -v Expected: FAIL — mergeBaseIntoChild doesn't handle Env.
- [ ] Step 3: Add env merge logic to
mergeBaseIntoChild
In internal/harness/compose.go, in the mergeBaseIntoChild function, after the RunnerEnv merge block (around line 460), add:
// Env: merge sub-maps independently, child keys win (ADR 0055)
if base.Env != nil {
if child.Env == nil {
child.Env = base.Env
} else {
if base.Env.Runner != nil {
if child.Env.Runner == nil {
child.Env.Runner = make(map[string]string, len(base.Env.Runner))
}
for k, v := range base.Env.Runner {
if _, exists := child.Env.Runner[k]; !exists {
child.Env.Runner[k] = v
}
}
}
if base.Env.Sandbox != nil {
if child.Env.Sandbox == nil {
child.Env.Sandbox = make(map[string]string, len(base.Env.Sandbox))
}
for k, v := range base.Env.Sandbox {
if _, exists := child.Env.Sandbox[k]; !exists {
child.Env.Sandbox[k] = v
}
}
}
}
}- [ ] Step 4: Run tests to verify they pass
Run: cd /home/rbean/code/fullsend-0 && go test ./internal/harness/ -run TestMergeBaseIntoChild_Env -v Expected: PASS
- [ ] Step 5: Add env merge to
mergeForgeConfigInto(for base forge blocks)
In internal/harness/compose.go, in the mergeForgeConfigInto function, after the RunnerEnv block, add:
// Env: merge sub-maps, child keys win (ADR 0055)
if base.Env != nil {
if child.Env == nil {
child.Env = base.Env
} else {
if base.Env.Runner != nil {
if child.Env.Runner == nil {
child.Env.Runner = make(map[string]string, len(base.Env.Runner))
}
for k, v := range base.Env.Runner {
if _, exists := child.Env.Runner[k]; !exists {
child.Env.Runner[k] = v
}
}
}
if base.Env.Sandbox != nil {
if child.Env.Sandbox == nil {
child.Env.Sandbox = make(map[string]string, len(base.Env.Sandbox))
}
for k, v := range base.Env.Sandbox {
if _, exists := child.Env.Sandbox[k]; !exists {
child.Env.Sandbox[k] = v
}
}
}
}
}- [ ] Step 6: Run full harness test suite
Run: cd /home/rbean/code/fullsend-0 && go test ./internal/harness/ -v Expected: All tests pass.
- [ ] Step 7: Commit
git add internal/harness/compose.go internal/harness/compose_test.go
git commit -S -s -m "$(cat <<'EOF'
feat(harness): merge env: in base composition
Wire EnvConfig into mergeBaseIntoChild and mergeForgeConfigInto so
env.runner and env.sandbox sub-maps merge correctly through base: chains
following the same per-variable additive rules as runner_env.
Assisted-by: Claude Opus 4.6 <noreply@anthropic.com>
EOF
)"Task 4: Add Lint() deprecation diagnostics for runner_env
Files:
Modify:
internal/harness/lint.go:40-42(Lint method)Test:
internal/harness/lint_test.go[ ] Step 1: Write failing tests for Lint deprecation warnings
In internal/harness/lint_test.go, add:
func TestLint_RunnerEnvDeprecated(t *testing.T) {
h := &Harness{
Agent: "agents/test.md",
Role: "test",
RunnerEnv: map[string]string{"FOO": "bar"},
}
diags := h.Lint()
require.Len(t, diags, 1)
assert.Equal(t, SeverityWarning, diags[0].Severity)
assert.Equal(t, "runner_env", diags[0].Field)
assert.Contains(t, diags[0].Message, "deprecated")
assert.Contains(t, diags[0].Message, "env.runner")
}
func TestLint_RunnerEnvAndEnvBothPresent(t *testing.T) {
h := &Harness{
Agent: "agents/test.md",
Role: "test",
RunnerEnv: map[string]string{"FOO": "bar"},
Env: &EnvConfig{Runner: map[string]string{"BAZ": "qux"}},
}
diags := h.Lint()
require.Len(t, diags, 1)
assert.Equal(t, SeverityWarning, diags[0].Severity)
assert.Contains(t, diags[0].Message, "env.runner takes precedence")
}
func TestLint_NoWarningWithoutRunnerEnv(t *testing.T) {
h := &Harness{
Agent: "agents/test.md",
Role: "test",
Env: &EnvConfig{Runner: map[string]string{"FOO": "bar"}},
}
diags := h.Lint()
assert.Empty(t, diags)
}- [ ] Step 2: Run tests to verify they fail
Run: cd /home/rbean/code/fullsend-0 && go test ./internal/harness/ -run TestLint_ -v Expected: FAIL — Lint() returns nil.
- [ ] Step 3: Implement Lint deprecation checks
Replace the Lint() method body in internal/harness/lint.go:
func (h *Harness) Lint() []Diagnostic {
var diags []Diagnostic
if len(h.RunnerEnv) > 0 {
msg := "runner_env is deprecated; use env.runner instead (see ADR 0055)"
if h.Env != nil && len(h.Env.Runner) > 0 {
msg = "runner_env is deprecated and env.runner takes precedence; migrate to env.runner (see ADR 0055)"
}
diags = append(diags, Diagnostic{
Severity: SeverityWarning,
Field: "runner_env",
Message: msg,
})
}
return diags
}- [ ] Step 4: Run tests to verify they pass
Run: cd /home/rbean/code/fullsend-0 && go test ./internal/harness/ -run TestLint_ -v Expected: PASS
- [ ] Step 5: Run full harness test suite
Run: cd /home/rbean/code/fullsend-0 && go test ./internal/harness/ -v Expected: All tests pass.
- [ ] Step 6: Commit
git add internal/harness/lint.go internal/harness/lint_test.go
git commit -S -s -m "$(cat <<'EOF'
feat(harness): lint deprecation warnings for runner_env
Lint() now emits a warning whenever runner_env is present, regardless of
whether env: also exists. When both are present, the warning notes that
env.runner takes precedence. Per ADR 0055.
Assisted-by: Claude Opus 4.6 <noreply@anthropic.com>
EOF
)"Task 5: Extend ValidateRunnerEnvWith to check env: and expand in the runner
Files:
Modify:
internal/harness/harness.go:490-519(ValidateRunnerEnvWith)Modify:
internal/cli/run.go:327-348(expand env.runner, apply precedence)Test:
internal/harness/harness_test.go[ ] Step 1: Write failing test for ValidateRunnerEnvWith checking env field
In internal/harness/harness_test.go, add:
func TestValidateRunnerEnvWith_ChecksEnvRunner(t *testing.T) {
h := &Harness{
Agent: "agents/test.md",
Role: "test",
Env: &EnvConfig{
Runner: map[string]string{"KEY": "${MISSING_VAR}"},
},
}
lookup := func(key string) (string, bool) { return "", false }
err := h.ValidateRunnerEnvWith(lookup)
require.Error(t, err)
assert.Contains(t, err.Error(), "MISSING_VAR")
}
func TestValidateRunnerEnvWith_ChecksEnvSandbox(t *testing.T) {
h := &Harness{
Agent: "agents/test.md",
Role: "test",
Env: &EnvConfig{
Sandbox: map[string]string{"KEY": "${ALSO_MISSING}"},
},
}
lookup := func(key string) (string, bool) { return "", false }
err := h.ValidateRunnerEnvWith(lookup)
require.Error(t, err)
assert.Contains(t, err.Error(), "ALSO_MISSING")
}
func TestValidateRunnerEnvWith_EnvAllSet(t *testing.T) {
h := &Harness{
Agent: "agents/test.md",
Role: "test",
Env: &EnvConfig{
Runner: map[string]string{"KEY": "${SET_VAR}"},
Sandbox: map[string]string{"KEY2": "literal"},
},
}
lookup := func(key string) (string, bool) {
if key == "SET_VAR" {
return "val", true
}
return "", false
}
err := h.ValidateRunnerEnvWith(lookup)
require.NoError(t, err)
}
func TestValidateRunnerEnvWith_NilEnvNoError(t *testing.T) {
h := &Harness{Agent: "agents/test.md", Role: "test"}
err := h.ValidateRunnerEnvWith(func(string) (string, bool) { return "", false })
require.NoError(t, err)
}- [ ] Step 2: Run tests to verify they fail
Run: cd /home/rbean/code/fullsend-0 && go test ./internal/harness/ -run TestValidateRunnerEnvWith_ChecksEnv -v Expected: FAIL — ValidateRunnerEnvWith doesn't check Env.
- [ ] Step 3: Extend
ValidateRunnerEnvWithto checkEnvfield
In internal/harness/harness.go, in ValidateRunnerEnvWith, after the HostFiles loop and before the return nil, add:
if h.Env != nil {
for k, v := range h.Env.Runner {
if err := checkVarRefs(fmt.Sprintf("env.runner[%s]", k), v); err != nil {
return err
}
}
for k, v := range h.Env.Sandbox {
if err := checkVarRefs(fmt.Sprintf("env.sandbox[%s]", k), v); err != nil {
return err
}
}
}- [ ] Step 4: Run tests to verify they pass
Run: cd /home/rbean/code/fullsend-0 && go test ./internal/harness/ -run TestValidateRunnerEnvWith -v Expected: PASS
- [ ] Step 5: Wire env.runner expansion and precedence into the runner
In internal/cli/run.go, find the block (around line 342) that expands RunnerEnv. After the existing expansion loop for RunnerEnv, add:
// Expand ${VAR} references in env.runner and env.sandbox (ADR 0055).
if h.Env != nil {
for k, v := range h.Env.Runner {
h.Env.Runner[k] = os.Expand(v, expander)
}
for k, v := range h.Env.Sandbox {
h.Env.Sandbox[k] = os.Expand(v, expander)
}
}
// ADR 0055: env.runner takes precedence over runner_env.
// Emit deprecation warning when runner_env is present.
if len(h.RunnerEnv) > 0 {
if h.Env != nil && len(h.Env.Runner) > 0 {
fmt.Fprintln(os.Stderr, "WARNING: runner_env is deprecated and env.runner takes precedence; migrate to env.runner (see ADR 0055)")
} else {
fmt.Fprintln(os.Stderr, "WARNING: runner_env is deprecated; use env.runner instead (see ADR 0055)")
}
}
// Build effective runner env: start with runner_env, overlay env.runner.
effectiveRunnerEnv := make(map[string]string)
for k, v := range h.RunnerEnv {
effectiveRunnerEnv[k] = v
}
if h.Env != nil {
for k, v := range h.Env.Runner {
effectiveRunnerEnv[k] = v
}
}
h.RunnerEnv = effectiveRunnerEnvThis preserves backward compatibility: RunnerEnv is still what gets passed to envToList() everywhere, but now it's the merged result with env.runner winning on collision.
- [ ] Step 6: Run full test suite
Run: cd /home/rbean/code/fullsend-0 && go test ./internal/harness/ ./internal/cli/ -v Expected: All tests pass.
- [ ] Step 7: Commit
git add internal/harness/harness.go internal/harness/harness_test.go internal/cli/run.go
git commit -S -s -m "$(cat <<'EOF'
feat(runner): validate, expand, and apply env.runner with precedence
Extend ValidateRunnerEnvWith to also check env.runner and env.sandbox
var refs. The runner expands ${VAR} references in both sub-maps, then
merges env.runner over runner_env (env.runner wins on collision).
Deprecation warnings are emitted to stderr whenever runner_env is
present. Per ADR 0055.
Assisted-by: Claude Opus 4.6 <noreply@anthropic.com>
EOF
)"Task 6: Generate sandbox .env file from env.sandbox
Files:
Modify:
internal/cli/run.go:1217-1268(bootstrapEnv function)Test:
internal/cli/run_test.go[ ] Step 1: Write failing test for sandbox env generation
In internal/cli/run_test.go, add (or find the appropriate test location):
func TestBuildSandboxEnvLines_FromEnvSandbox(t *testing.T) {
h := &harness.Harness{
Agent: "agents/test.md",
Role: "test",
Env: &harness.EnvConfig{
Sandbox: map[string]string{
"GITHUB_PR_URL": "https://github.com/org/repo/pull/1",
"GH_TOKEN": "tok123",
},
},
}
lines := buildSandboxEnvLines(h)
assert.Contains(t, lines, "export GITHUB_PR_URL='https://github.com/org/repo/pull/1'")
assert.Contains(t, lines, "export GH_TOKEN='tok123'")
}- [ ] Step 2: Run test to verify it fails
Run: cd /home/rbean/code/fullsend-0 && go test ./internal/cli/ -run TestBuildSandboxEnvLines -v Expected: FAIL — function doesn't exist.
- [ ] Step 3: Implement
buildSandboxEnvLines
In internal/cli/run.go, add a helper function:
// buildSandboxEnvLines generates export lines for env.sandbox values (ADR 0055).
// Values have already been expanded by the caller. Each value is single-quoted
// with internal single quotes escaped.
func buildSandboxEnvLines(h *harness.Harness) []string {
if h.Env == nil || len(h.Env.Sandbox) == 0 {
return nil
}
keys := make([]string, 0, len(h.Env.Sandbox))
for k := range h.Env.Sandbox {
keys = append(keys, k)
}
sort.Strings(keys)
lines := make([]string, 0, len(keys))
for _, k := range keys {
v := h.Env.Sandbox[k]
escaped := strings.ReplaceAll(v, "'", "'\\''")
lines = append(lines, fmt.Sprintf("export %s='%s'", k, escaped))
}
return lines
}Add "sort" to the import block if not already present.
- [ ] Step 4: Run test to verify it passes
Run: cd /home/rbean/code/fullsend-0 && go test ./internal/cli/ -run TestBuildSandboxEnvLines -v Expected: PASS
- [ ] Step 5: Wire
buildSandboxEnvLinesintobootstrapEnv
In internal/cli/run.go, in the bootstrapEnv function, find the line that appends the .env.d sourcing loop (around line 1264):
// Source all env files from .env.d/ (populated by host_files with expand: true).
lines = append(lines, fmt.Sprintf("for f in %s/.env.d/*.env; do [ -f \"$f\" ] && . \"$f\"; done", sandbox.SandboxWorkspace))Add the env.sandbox lines before the .env.d sourcing loop so that manual .env files (if still present) override generated values (last-writer-wins per ADR 0055):
// ADR 0055: export env.sandbox vars. Placed before .env.d sourcing so
// manual .env files (if still present during migration) win on collision.
lines = append(lines, buildSandboxEnvLines(h)...)
// Source all env files from .env.d/ (populated by host_files with expand: true).
lines = append(lines, fmt.Sprintf("for f in %s/.env.d/*.env; do [ -f \"$f\" ] && . \"$f\"; done", sandbox.SandboxWorkspace))- [ ] Step 6: Run full test suite
Run: cd /home/rbean/code/fullsend-0 && go test ./internal/cli/ ./internal/harness/ -v Expected: All tests pass.
- [ ] Step 7: Commit
git add internal/cli/run.go internal/cli/run_test.go
git commit -S -s -m "$(cat <<'EOF'
feat(runner): generate sandbox env from env.sandbox
The runner now exports env.sandbox key-value pairs into the sandbox's
.env file at bootstrap. These are placed before the .env.d sourcing
loop so that manual .env files (if still present during migration)
take precedence per ADR 0055's last-writer-wins guarantee.
Assisted-by: Claude Opus 4.6 <noreply@anthropic.com>
EOF
)"Task 7: Integration test — full harness load with env:
Files:
Test:
internal/harness/integration_test.go[ ] Step 1: Write integration test
In internal/harness/integration_test.go, add a test that exercises the full load pipeline with env:, forge:, and base::
func TestLoadWithBase_EnvMergesThroughFullPipeline(t *testing.T) {
dir := t.TempDir()
baseYAML := `
agent: agents/test.md
role: test
env:
runner:
BASE_R: base_r
SHARED: from_base
sandbox:
BASE_S: base_s
forge:
github:
env:
runner:
GH_R: gh_r
sandbox:
GH_S: gh_s
`
childYAML := `
base: base.yaml
env:
runner:
SHARED: from_child
CHILD_R: child_r
sandbox:
CHILD_S: child_s
`
require.NoError(t, os.WriteFile(filepath.Join(dir, "base.yaml"), []byte(baseYAML), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(dir, "child.yaml"), []byte(childYAML), 0o644))
// Create the referenced agent file
require.NoError(t, os.MkdirAll(filepath.Join(dir, "agents"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(dir, "agents/test.md"), []byte("# test"), 0o644))
ctx := context.Background()
h, _, err := LoadWithBase(ctx, filepath.Join(dir, "child.yaml"), ComposeOpts{
WorkspaceRoot: dir,
ForgePlatform: "github",
})
require.NoError(t, err)
require.NotNil(t, h.Env)
// Base composition: child wins on SHARED
assert.Equal(t, "from_child", h.Env.Runner["SHARED"])
// Base composition: BASE_R inherited
assert.Equal(t, "base_r", h.Env.Runner["BASE_R"])
// Child's own
assert.Equal(t, "child_r", h.Env.Runner["CHILD_R"])
// Forge resolution: GH_R merged in
assert.Equal(t, "gh_r", h.Env.Runner["GH_R"])
// Sandbox side
assert.Equal(t, "base_s", h.Env.Sandbox["BASE_S"])
assert.Equal(t, "child_s", h.Env.Sandbox["CHILD_S"])
assert.Equal(t, "gh_s", h.Env.Sandbox["GH_S"])
}- [ ] Step 2: Run test
Run: cd /home/rbean/code/fullsend-0 && go test ./internal/harness/ -run TestLoadWithBase_EnvMergesThroughFullPipeline -v Expected: PASS (all prior tasks should make this work).
- [ ] Step 3: Run full test suite
Run: cd /home/rbean/code/fullsend-0 && go test ./... 2>&1 | tail -30 Expected: All tests pass.
- [ ] Step 4: Commit
git add internal/harness/integration_test.go
git commit -S -s -m "$(cat <<'EOF'
test(harness): integration test for env: through full load pipeline
Exercises base composition + forge resolution together to verify
env.runner and env.sandbox merge correctly end-to-end.
Assisted-by: Claude Opus 4.6 <noreply@anthropic.com>
EOF
)"Task 8: Stage and lint all changes
- [ ] Step 1: Run linters
cd /home/rbean/code/fullsend-0 && git add -A && make lintExpected: All linters pass. Fix any issues.
- [ ] Step 2: Run full test suite one more time
cd /home/rbean/code/fullsend-0 && make go-testExpected: All tests pass.
- [ ] Step 3: Run go vet
cd /home/rbean/code/fullsend-0 && make go-vetExpected: No issues.
