diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d1de89..feb6429 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,41 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added +- `runtime.Runtime.MergeEnv(env)`: new interface method that lets + setup-time callers (notably each agent's `Install`) seed the runtime's + persistent env baseline. Subsequent `Exec` calls inherit these vars + unless overridden by `opts.Env`. Implementations are intended for + one-time setup; concurrent `MergeEnv`/`Exec` is not safe. + +### Changed +- Agent layer no longer injects PATH into the env map. Each agent's + `Install` now probes the target shell for the resolved PATH (a + per-agent `printf '%s' "$HOME/..."` command) and merges the literal + result into the runtime's env baseline via `runtime.MergeEnv`. + Runtimes treat env keys uniformly — no key is special-cased anywhere. +- `NoneRuntime` no longer expands `$VAR` / `${VAR}` in user-provided env + values (`environment.env` / `opts.Env`). Values forward literally, + matching docker and opensandbox. If your `eval.yaml` relied on + host-side expansion (e.g. `MY_VAR: "$HOME/foo"`), switch to a literal + value or `export` it inside a `setup_steps` step. +- `docker` / `opensandbox`: the same "values forward literally" rule + now applies — previously the docker runtime silently dropped + `environment.env.PATH` values containing `$` (the alternative was + passing them to `docker create --env` literally, which would have + broken container startup because the entrypoint's PATH lookup can't + resolve directories named `$HOME` / `$PATH`). After this PR no key + is special-cased, so passing such a value will now make the + container/sandbox fail to start; use a literal PATH or move the + manipulation into a `setup_steps` `export`. +- `environment.type: none`: the framework no longer force-prepends + `$HOME/.local/bin:$HOME/.nvm/current/bin:$PATH` to agent commands. + Because `ag.Install` is skipped for type=none, the new probe doesn't + run either; the host shell's PATH is used as-is. Add the dirs to + your shell rc if `claude` / `codex` / `qodercli` isn't already on it. + ## [0.2.3] - 2026-05-27 ### Added diff --git a/e2e/pipeline_test.go b/e2e/pipeline_test.go index a803ed7..03260ad 100644 --- a/e2e/pipeline_test.go +++ b/e2e/pipeline_test.go @@ -14,12 +14,12 @@ import ( // mockEngineHome creates a fake HOME whose $HOME/.local/bin contains symlinks // named "qodercli", "claude", "codex" (all pointing at mock-engine/engine.sh). -// We cannot merely prepend a directory to PATH, because the agent layer forces -// PATH="$HOME/.local/bin:$HOME/.nvm/current/bin:$PATH" before running the -// engine (see internal/agent.agentExecutablePath). That means any mock we put -// on PATH would be shadowed by the real qodercli in the developer's real -// ~/.local/bin. By taking over HOME we make the agent layer look inside our -// fake tree first, which guarantees the mock wins. +// The e2e pipeline uses environment.type: none and supplies PATH explicitly +// via mockEngineEnv, so the framework's normal probe-PATH-at-Install flow +// (see internal/agent.probeAndMergePATH, only invoked for envType != "none") +// doesn't run here. We still override HOME so that the symlinks under our +// fake $HOME/.local/bin win over any real claude/codex/qodercli on the +// developer's machine. func mockEngineHome(t *testing.T) (home, binDir string) { t.Helper() diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 0411650..d2e832d 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -114,7 +114,6 @@ const ExitCodeSignalKilled = -1 const ( agentProviderOpenAI = "openai" agentProviderAnthropic = "anthropic" - agentExecutablePath = "$HOME/.local/bin:$HOME/.nvm/current/bin:$PATH" ) // NewBaseAgent creates a new BaseAgent with the given config. @@ -258,7 +257,7 @@ func downloadSessionArtifact(ctx context.Context, rt Runtime, artifactDir, sessi } func (a *BaseAgent) mergeExecOptionsEnv(ctx context.Context, opts ExecOptions, envVars map[string]string, attrs map[string]string) ExecOptions { - merged := map[string]string{"PATH": agentExecutablePath} + merged := map[string]string{} maps.Copy(merged, envVars) maps.Copy(merged, opts.Env) maps.Copy(merged, observability.AgentEnv(ctx, merged, attrs)) @@ -266,6 +265,27 @@ func (a *BaseAgent) mergeExecOptionsEnv(ctx context.Context, opts ExecOptions, e return opts } +// probeAndMergePATH runs probeCmd against rt and merges the literal result +// into rt's env baseline under key "PATH". Each agent's Install calls this +// with its own probeCmd (e.g. claudeCodeExecPathProbeCmd) so the resolved +// PATH covers the directories where that agent's binaries actually live. +// +// On probe failure the runtime's default PATH stands and a warning is +// logged; the install command still runs (its own bootstrap, if any, may +// still succeed). +func (a *BaseAgent) probeAndMergePATH(ctx context.Context, rt Runtime, probeCmd string) { + res, err := rt.Exec(ctx, probeCmd, ExecOptions{}) + if err != nil || res.ExitCode != 0 { + logging.WarnContextf(ctx, "agent %s: PATH probe failed (err=%v exit=%d); using runtime default PATH", a.Name(), err, res.ExitCode) + return + } + path := strings.TrimSpace(res.Stdout) + if path == "" { + return + } + rt.MergeEnv(map[string]string{"PATH": path}) +} + func (a *BaseAgent) buildAgentObservabilityAttrs(extra map[string]string) map[string]string { attrs := mapsClone(extra) if attrs == nil { diff --git a/internal/agent/agent_test.go b/internal/agent/agent_test.go index 3dfd993..0086ff4 100644 --- a/internal/agent/agent_test.go +++ b/internal/agent/agent_test.go @@ -3,6 +3,7 @@ package agent import ( "context" "errors" + "maps" "os" "path/filepath" "strings" @@ -202,8 +203,8 @@ func TestBaseAgentMergeExecOptionsEnvMergesRuntimeAndTelemetry(t *testing.T) { if got := opts.Env["BASE_ONLY"]; got != "1" { t.Fatalf("BASE_ONLY = %q, want preserved base env", got) } - if got := opts.Env["PATH"]; got != agentExecutablePath { - t.Fatalf("PATH = %q, want agent executable path", got) + if _, ok := opts.Env["PATH"]; ok { + t.Fatalf("PATH should not be injected by mergeExecOptionsEnv; PATH now flows from runtime baseline via probeAndMergePATH. got %q", opts.Env["PATH"]) } if got := opts.Env["OTEL_EXPORTER_OTLP_ENDPOINT"]; got != "http://call-collector:4318" { t.Fatalf("OTEL_EXPORTER_OTLP_ENDPOINT = %q, want call env to override process env", got) @@ -242,6 +243,80 @@ func TestMergeExecOptionsEnv_PreservesConfiguredPATH(t *testing.T) { } } +// probeMergeTestRuntime captures probe Exec calls and MergeEnv calls so +// TestProbeAndMergePATH can verify the helper's wiring without dragging +// in a full agent test fixture. +type probeMergeTestRuntime struct { + probeCmd string + probeStdout string + probeExit int + probeErr error + merged map[string]string +} + +func (r *probeMergeTestRuntime) Create(context.Context) error { return nil } +func (r *probeMergeTestRuntime) Close() error { return nil } +func (r *probeMergeTestRuntime) Start(context.Context) error { return nil } +func (r *probeMergeTestRuntime) Stop(context.Context) error { return nil } +func (r *probeMergeTestRuntime) UploadFile(context.Context, string, string) error { return nil } +func (r *probeMergeTestRuntime) UploadDir(context.Context, string, string) error { return nil } +func (r *probeMergeTestRuntime) DownloadFile(context.Context, string, string) error { + return nil +} +func (r *probeMergeTestRuntime) DownloadDir(context.Context, string, string) error { return nil } +func (r *probeMergeTestRuntime) Workspace() string { return "" } +func (r *probeMergeTestRuntime) RequiresProcessSandbox() bool { return false } +func (r *probeMergeTestRuntime) Exec(_ context.Context, cmd string, _ ExecOptions) (ExecResult, error) { + r.probeCmd = cmd + return ExecResult{Stdout: r.probeStdout, ExitCode: r.probeExit}, r.probeErr +} + +func (r *probeMergeTestRuntime) MergeEnv(env map[string]string) { + if r.merged == nil { + r.merged = make(map[string]string, len(env)) + } + maps.Copy(r.merged, env) +} + +func TestProbeAndMergePATH_HappyPath(t *testing.T) { + t.Parallel() + base := NewBaseAgent(Config{Name: "claude-code"}) + rt := &probeMergeTestRuntime{probeStdout: " /resolved/bin:/usr/bin\n"} + + base.probeAndMergePATH(context.Background(), rt, `printf '%s' "$HOME/.local/bin:$PATH"`) + + if rt.probeCmd != `printf '%s' "$HOME/.local/bin:$PATH"` { + t.Fatalf("probe cmd = %q, want the supplied probeCmd verbatim", rt.probeCmd) + } + if got := rt.merged["PATH"]; got != "/resolved/bin:/usr/bin" { + t.Fatalf("merged PATH = %q, want trimmed probe stdout", got) + } +} + +func TestProbeAndMergePATH_SkipsMergeOnProbeFailure(t *testing.T) { + t.Parallel() + base := NewBaseAgent(Config{Name: "claude-code"}) + rt := &probeMergeTestRuntime{probeExit: 127, probeStdout: "garbage"} + + base.probeAndMergePATH(context.Background(), rt, `printf '%s' "$HOME/.local/bin:$PATH"`) + + if rt.merged != nil { + t.Fatalf("MergeEnv should not have been called on probe failure; got %+v", rt.merged) + } +} + +func TestProbeAndMergePATH_SkipsMergeOnEmptyStdout(t *testing.T) { + t.Parallel() + base := NewBaseAgent(Config{Name: "claude-code"}) + rt := &probeMergeTestRuntime{probeStdout: " \n"} // whitespace only + + base.probeAndMergePATH(context.Background(), rt, `printf '%s' "$HOME/.local/bin:$PATH"`) + + if rt.merged != nil { + t.Fatalf("MergeEnv should not have been called on empty probe stdout; got %+v", rt.merged) + } +} + func TestDetectAgentWithInitParams_SetsTypedCredentialFields(t *testing.T) { t.Parallel() diff --git a/internal/agent/claude_code.go b/internal/agent/claude_code.go index 651700d..12d1008 100644 --- a/internal/agent/claude_code.go +++ b/internal/agent/claude_code.go @@ -26,6 +26,12 @@ type ClaudeCodeAgent struct { const claudeCodePackage = "@anthropic-ai/claude-code" +// claudeCodeExecPathProbeCmd resolves both $HOME/.local/bin (where `npm +// install -g` puts the claude binary via the bootstrap's npm_config_prefix) +// and $HOME/.nvm/current/bin (where the node interpreter lives — claude's +// `#!/usr/bin/env node` shebang needs to find node at exec time). +const claudeCodeExecPathProbeCmd = `printf '%s' "$HOME/.local/bin:$HOME/.nvm/current/bin:$PATH"` + // NewClaudeCodeAgent creates a new ClaudeCodeAgent. func NewClaudeCodeAgent(cfg Config) *ClaudeCodeAgent { if cfg.Name == "" { @@ -40,7 +46,11 @@ func NewClaudeCodeAgent(cfg Config) *ClaudeCodeAgent { } // Install installs Claude Code when it is not already available in the runtime. +// +//nolint:dupl // each agent Install shares the same probe→merge→exec lifecycle; the deltas (probe const, default install cmd) are pulled out, leaving the orchestration intentionally similar. func (a *ClaudeCodeAgent) Install(ctx context.Context, rt Runtime) error { + a.probeAndMergePATH(ctx, rt, claudeCodeExecPathProbeCmd) + opts := ExecOptions{Cwd: "/"} opts = a.mergeExecOptionsEnv(ctx, opts, nil, nil) diff --git a/internal/agent/claude_code_test.go b/internal/agent/claude_code_test.go index 72c93dd..f996a23 100644 --- a/internal/agent/claude_code_test.go +++ b/internal/agent/claude_code_test.go @@ -3,6 +3,7 @@ package agent import ( "context" "errors" + "maps" "os" "path/filepath" "strings" @@ -82,8 +83,11 @@ func TestClaudeCodeInstall_UsesDefaultCommand(t *testing.T) { if !strings.Contains(rt.lastCommand, "npm install -g --include=optional '"+claudeCodePackage+"'") { t.Fatalf("install command does not install claude-code:\n%s", rt.lastCommand) } - if got := rt.lastExecEnv["PATH"]; got != agentExecutablePath { - t.Fatalf("install PATH = %q, want agent executable path", got) + if _, ok := rt.lastExecEnv["PATH"]; ok { + t.Fatalf("install env should not carry PATH from agent; PATH flows via runtime baseline. got %q", rt.lastExecEnv["PATH"]) + } + if got := rt.mergedEnv["PATH"]; got == "" { + t.Fatalf("expected probeAndMergePATH to populate runtime baseline with PATH; mergedEnv=%+v", rt.mergedEnv) } if got := rt.lastExecEnv[credential.EnvAnthropicAPIKey]; got != "" { t.Fatalf("install env leaked %s = %q", credential.EnvAnthropicAPIKey, got) @@ -700,11 +704,13 @@ func TestProviderRateLimitSignal_PrefersSessionFinalMessage(t *testing.T) { } type claudeCodeTestRuntime struct { - workspace string - execResult runtime.ExecResult - lastCommand string - lastExecEnv map[string]string - execCount int + workspace string + execResult runtime.ExecResult + lastCommand string + lastExecEnv map[string]string + execCount int + probeResponseStdout string // canned stdout for PATH probe; defaults to a fake bin + mergedEnv map[string]string // accumulates entries from MergeEnv calls } func (r *claudeCodeTestRuntime) Create(context.Context) error { return nil } @@ -728,6 +734,18 @@ func (r *claudeCodeTestRuntime) DownloadDir(context.Context, string, string) err } func (r *claudeCodeTestRuntime) Exec(_ context.Context, command string, opts runtime.ExecOptions) (runtime.ExecResult, error) { + // Probe calls (issued by agent.Install via probeAndMergePATH) get a + // canned literal PATH and are NOT recorded as the agent's own + // command. Match the exact probe constant rather than a prefix so a + // future test that legitimately runs `printf '%s' "$HOME/..."` for + // some other purpose isn't silently swallowed. + if command == claudeCodeExecPathProbeCmd { + stdout := r.probeResponseStdout + if stdout == "" { + stdout = "/fake/.local/bin:/fake/.nvm/current/bin:/usr/bin" + } + return runtime.ExecResult{Stdout: stdout}, nil + } r.lastCommand = command r.execCount++ if r.execCount == 1 { @@ -741,3 +759,10 @@ func (r *claudeCodeTestRuntime) Workspace() string { return r.workspace } func (r *claudeCodeTestRuntime) RequiresProcessSandbox() bool { return true } + +func (r *claudeCodeTestRuntime) MergeEnv(env map[string]string) { + if r.mergedEnv == nil { + r.mergedEnv = make(map[string]string, len(env)) + } + maps.Copy(r.mergedEnv, env) +} diff --git a/internal/agent/codex.go b/internal/agent/codex.go index df6eb3a..caa8239 100644 --- a/internal/agent/codex.go +++ b/internal/agent/codex.go @@ -49,6 +49,11 @@ const ( codexStatusError = "error" ) +// codexExecPathProbeCmd resolves $HOME/.local/bin (the codex binary, installed +// via `npm install -g` under the bootstrap's npm_config_prefix) and +// $HOME/.nvm/current/bin (node, needed by codex's #!/usr/bin/env node shebang). +const codexExecPathProbeCmd = `printf '%s' "$HOME/.local/bin:$HOME/.nvm/current/bin:$PATH"` + // NewCodexAgent creates a new CodexAgent. func NewCodexAgent(cfg Config) *CodexAgent { if cfg.Name == "" { @@ -67,7 +72,11 @@ func NewCodexAgent(cfg Config) *CodexAgent { } // Install installs Codex CLI when it is not already available in the runtime. +// +//nolint:dupl // each agent Install shares the same probe→merge→exec lifecycle; the deltas (probe const, default install cmd) are pulled out, leaving the orchestration intentionally similar. func (a *CodexAgent) Install(ctx context.Context, rt Runtime) error { + a.probeAndMergePATH(ctx, rt, codexExecPathProbeCmd) + opts := ExecOptions{Cwd: "/"} opts = a.mergeExecOptionsEnv(ctx, opts, nil, nil) diff --git a/internal/agent/codex_test.go b/internal/agent/codex_test.go index 49053e2..662b392 100644 --- a/internal/agent/codex_test.go +++ b/internal/agent/codex_test.go @@ -3,6 +3,7 @@ package agent import ( "context" "fmt" + "maps" "os" "path/filepath" "strings" @@ -98,8 +99,11 @@ func TestCodexInstall_DefaultsToPinnedVersion(t *testing.T) { if !strings.Contains(rt.lastCommand, "@openai/codex@"+codexDefaultVersion) { t.Fatalf("install command %q does not pin codex version %s", rt.lastCommand, codexDefaultVersion) } - if got := rt.lastExecEnv["PATH"]; got != agentExecutablePath { - t.Fatalf("install PATH = %q, want agent executable path", got) + if _, ok := rt.lastExecEnv["PATH"]; ok { + t.Fatalf("install env should not carry PATH from agent; PATH flows via runtime baseline. got %q", rt.lastExecEnv["PATH"]) + } + if got := rt.mergedEnv["PATH"]; got == "" { + t.Fatalf("expected probeAndMergePATH to populate runtime baseline with PATH; mergedEnv=%+v", rt.mergedEnv) } if got := rt.lastExecEnv[credential.EnvOpenAIAPIKey]; got != "" { t.Fatalf("install env leaked %s = %q", credential.EnvOpenAIAPIKey, got) @@ -948,15 +952,17 @@ func TestCodexRun_PropagatesObservabilityEnv(t *testing.T) { } type codexTestRuntime struct { - workspace string - sessionPath string - sessionBytes []byte - lastMessagePath string - lastMessageBytes []byte - execResult runtime.ExecResult - commands []string - lastCommand string - lastExecEnv map[string]string + workspace string + sessionPath string + sessionBytes []byte + lastMessagePath string + lastMessageBytes []byte + execResult runtime.ExecResult + commands []string + lastCommand string + lastExecEnv map[string]string + probeResponseStdout string + mergedEnv map[string]string } func (r *codexTestRuntime) Create(context.Context) error { return nil } @@ -982,6 +988,17 @@ func (r *codexTestRuntime) DownloadFile(_ context.Context, sourcePath, targetPat } func (r *codexTestRuntime) DownloadDir(context.Context, string, string) error { return nil } func (r *codexTestRuntime) Exec(_ context.Context, command string, opts runtime.ExecOptions) (runtime.ExecResult, error) { + // Probe calls (agent.Install via probeAndMergePATH) get a canned + // literal PATH and are NOT recorded as a real command. Exact-match + // the probe constant so unrelated `printf '%s' "$HOME/..."` tests + // aren't silently intercepted. + if command == codexExecPathProbeCmd { + stdout := r.probeResponseStdout + if stdout == "" { + stdout = "/fake/.local/bin:/fake/.nvm/current/bin:/usr/bin" + } + return runtime.ExecResult{Stdout: stdout}, nil + } r.commands = append(r.commands, command) r.lastCommand = command if strings.Contains(command, "SKILL_UP_CODEX_THREAD_ID") { @@ -1006,3 +1023,10 @@ func (r *codexTestRuntime) Workspace() string { return r.workspace } func (r *codexTestRuntime) RequiresProcessSandbox() bool { return r.workspace != "opensandbox" } + +func (r *codexTestRuntime) MergeEnv(env map[string]string) { + if r.mergedEnv == nil { + r.mergedEnv = make(map[string]string, len(env)) + } + maps.Copy(r.mergedEnv, env) +} diff --git a/internal/agent/qodercli.go b/internal/agent/qodercli.go index 08dc2b0..1fc97e5 100644 --- a/internal/agent/qodercli.go +++ b/internal/agent/qodercli.go @@ -22,6 +22,11 @@ type QoderCLIAgent struct { var supportedQoderModels = []string{"lite", "efficient", "auto", "performance", "ultimate"} +// qoderExecPathProbeCmd resolves $HOME/.local/bin only — qodercli is a +// self-contained binary placed there by the official installer, not a node +// script, so the nvm path is unneeded. +const qoderExecPathProbeCmd = `printf '%s' "$HOME/.local/bin:$PATH"` + // NewQoderCLIAgent creates a new QoderCLIAgent. func NewQoderCLIAgent(cfg Config) *QoderCLIAgent { if cfg.Name == "" { @@ -185,7 +190,11 @@ func findQoderSessionFile(ctx context.Context, rt Runtime) string { } // Install installs qoder CLI via official install script. +// +//nolint:dupl // each agent Install shares the same probe→merge→exec lifecycle; the deltas (probe const, default install cmd) are pulled out, leaving the orchestration intentionally similar. func (a *QoderCLIAgent) Install(ctx context.Context, rt Runtime) error { + a.probeAndMergePATH(ctx, rt, qoderExecPathProbeCmd) + opts := ExecOptions{Cwd: "/"} opts = a.mergeExecOptionsEnv(ctx, opts, nil, nil) diff --git a/internal/agent/qodercli_test.go b/internal/agent/qodercli_test.go index 7294163..12d5e47 100644 --- a/internal/agent/qodercli_test.go +++ b/internal/agent/qodercli_test.go @@ -2,6 +2,7 @@ package agent import ( "context" + "maps" "os" "path/filepath" "strings" @@ -135,8 +136,11 @@ func TestQoderCLIInstall_UsesDefaultCommand(t *testing.T) { if !strings.Contains(rt.lastCommand, "curl -fsSL https://qoder.com/install | bash") { t.Fatalf("install command does not run qoder installer:\n%s", rt.lastCommand) } - if got := rt.lastExecEnv["PATH"]; got != agentExecutablePath { - t.Fatalf("install PATH = %q, want agent executable path", got) + if _, ok := rt.lastExecEnv["PATH"]; ok { + t.Fatalf("install env should not carry PATH from agent; PATH flows via runtime baseline. got %q", rt.lastExecEnv["PATH"]) + } + if got := rt.mergedEnv["PATH"]; got == "" { + t.Fatalf("expected probeAndMergePATH to populate runtime baseline with PATH; mergedEnv=%+v", rt.mergedEnv) } } @@ -366,10 +370,12 @@ func TestFindQoderSessionFileSymlink(t *testing.T) { } type qoderTestRuntime struct { - workspace string - execResult runtime.ExecResult - lastCommand string - lastExecEnv map[string]string + workspace string + execResult runtime.ExecResult + lastCommand string + lastExecEnv map[string]string + probeResponseStdout string + mergedEnv map[string]string } func (r *qoderTestRuntime) Create(context.Context) error { return nil } @@ -385,6 +391,17 @@ func (r *qoderTestRuntime) DownloadFile(context.Context, string, string) error { } func (r *qoderTestRuntime) DownloadDir(context.Context, string, string) error { return nil } func (r *qoderTestRuntime) Exec(_ context.Context, command string, opts runtime.ExecOptions) (runtime.ExecResult, error) { + // Probe calls (agent.Install via probeAndMergePATH) get a canned + // literal PATH and are NOT recorded as a real command. Exact-match + // the probe constant so unrelated `printf '%s' "$HOME/..."` tests + // aren't silently intercepted. + if command == qoderExecPathProbeCmd { + stdout := r.probeResponseStdout + if stdout == "" { + stdout = "/fake/.local/bin:/usr/bin" + } + return runtime.ExecResult{Stdout: stdout}, nil + } r.lastCommand = command if strings.Contains(command, "qodercli --permission-mode=bypass_permissions") || strings.Contains(command, "qodercli -p ") || @@ -397,3 +414,10 @@ func (r *qoderTestRuntime) Workspace() string { return r.workspace } func (r *qoderTestRuntime) RequiresProcessSandbox() bool { return true } + +func (r *qoderTestRuntime) MergeEnv(env map[string]string) { + if r.mergedEnv == nil { + r.mergedEnv = make(map[string]string, len(env)) + } + maps.Copy(r.mergedEnv, env) +} diff --git a/internal/evaluator/evaluator_test.go b/internal/evaluator/evaluator_test.go index a6e1f9a..4ace9d9 100644 --- a/internal/evaluator/evaluator_test.go +++ b/internal/evaluator/evaluator_test.go @@ -96,6 +96,7 @@ func (m *mockRuntime) Create(_ context.Context) error { return func (m *mockRuntime) Close() error { return nil } func (m *mockRuntime) Workspace() string { return m.workspace } func (m *mockRuntime) RequiresProcessSandbox() bool { return true } +func (m *mockRuntime) MergeEnv(_ map[string]string) {} func (m *mockRuntime) Start(_ context.Context) error { return nil } func (m *mockRuntime) Stop(_ context.Context) error { return nil } func (m *mockRuntime) UploadFile(_ context.Context, _, _ string) error { return nil } diff --git a/internal/judge/helpers_test.go b/internal/judge/helpers_test.go index 5b3fc0f..d31c75f 100644 --- a/internal/judge/helpers_test.go +++ b/internal/judge/helpers_test.go @@ -120,6 +120,7 @@ func (m *mockJudgeTestRuntime) Create(_ context.Context) error func (m *mockJudgeTestRuntime) Close() error { return nil } func (m *mockJudgeTestRuntime) Workspace() string { return "/tmp/test" } func (m *mockJudgeTestRuntime) RequiresProcessSandbox() bool { return true } +func (m *mockJudgeTestRuntime) MergeEnv(_ map[string]string) {} func (m *mockJudgeTestRuntime) Start(_ context.Context) error { return nil } func (m *mockJudgeTestRuntime) Stop(_ context.Context) error { return nil } func (m *mockJudgeTestRuntime) UploadFile(_ context.Context, _, _ string) error { return nil } diff --git a/internal/judge/script_test.go b/internal/judge/script_test.go index 7c4dce8..31b00a0 100644 --- a/internal/judge/script_test.go +++ b/internal/judge/script_test.go @@ -247,6 +247,7 @@ func (r *scriptJudgeRuntime) Start(context.Context) error { return nil } func (r *scriptJudgeRuntime) Stop(context.Context) error { return nil } func (r *scriptJudgeRuntime) Workspace() string { return r.workspace } func (r *scriptJudgeRuntime) RequiresProcessSandbox() bool { return true } +func (r *scriptJudgeRuntime) MergeEnv(_ map[string]string) {} func (r *scriptJudgeRuntime) UploadFile(_ context.Context, _, targetPath string) error { r.uploads = append(r.uploads, targetPath) diff --git a/internal/runtime/docker.go b/internal/runtime/docker.go index 5b19f07..445e530 100644 --- a/internal/runtime/docker.go +++ b/internal/runtime/docker.go @@ -165,9 +165,6 @@ func (r *DockerRuntime) buildCreateArgs(name string) []string { args = append(args, "--network", "none") } for k, v := range r.cfg.Env { - if k == "PATH" && strings.Contains(v, "$") { - continue - } args = append(args, "--env", k+"="+v) } entry := r.cfg.Entrypoint @@ -410,31 +407,13 @@ func (r *DockerRuntime) Exec(ctx context.Context, command string, opts ExecOptio } } args = append(args, "--workdir", cwd) - // mergeEnv covers cfg.Env + opts.Env, but here we want to keep the - // container's own env (PATH, HOME, ...) intact and just layer the - // caller-supplied vars on top — so pass only the merged overlay to - // docker, not the full host env. - // - // PATH requires special handling: docker --env sets values literally - // (no variable expansion), so "$HOME/.local/bin:$PATH" would become - // the literal string. Instead, prepend an `export PATH=...` line to - // the command so expansion happens inside the container's shell. - var pathPrefix string + // Pass only the cfg.Env + opts.Env overlay to docker, not the full + // host env, so the container's own PATH/HOME/... stay intact and + // only caller-supplied vars layer on top. Values are forwarded + // literally; callers that need shell expansion (e.g. an agent's + // $HOME-referencing PATH) should resolve the value first and pass + // the literal — see internal/agent.probeAndMergePATH. for _, kv := range overlayEnvList(r.cfg.Env, opts.Env) { - if k, v, _ := strings.Cut(kv, "="); k == "PATH" && strings.Contains(v, "$") { - // shellDoubleQuote escapes \ " `, but not $ — so $VAR - // expansion still works (intended), but $(...) command - // substitution would also still fire and silently - // execute arbitrary commands. Reject the substitution - // form rather than try to escape it (escaping $ would - // also kill the legitimate $VAR / ${VAR} expansion this - // branch exists for). - if strings.Contains(v, "$(") { - return ExecResult{}, fmt.Errorf("docker runtime: PATH %q contains command substitution $(...), which is not allowed", v) - } - pathPrefix = "export PATH=" + shellDoubleQuote(v) + "\n" - continue - } args = append(args, "--env", kv) } // Use `sh -c` rather than `bash -c` so the docker runtime works on @@ -447,7 +426,7 @@ func (r *DockerRuntime) Exec(ctx context.Context, command string, opts ExecOptio // install, MCP install, judge scripts) is shell-driven end-to-end, // so an image without /bin/sh isn't usable here even if Exec // itself bypassed `sh -c`. Pick a base image with a POSIX shell. - args = append(args, id, "sh", "-c", pathPrefix+command) + args = append(args, id, "sh", "-c", command) span.SetAttributes( attribute.String("process.command", command), attribute.String("process.cwd", cwd), @@ -514,6 +493,15 @@ func (r *DockerRuntime) RequiresProcessSandbox() bool { return false } +// MergeEnv layers entries into the runtime's persistent env baseline. See +// Runtime.MergeEnv for the contract. Note: the container's entrypoint (e.g. +// `sleep infinity`) is started with the env present at Create time, so +// post-Create MergeEnv calls only affect subsequent `docker exec` +// invocations, not the long-running entrypoint process. +func (r *DockerRuntime) MergeEnv(env map[string]string) { + mergeIntoEnvBaseline(&r.cfg.Env, env) +} + // snapshotContainerID returns the current container id under the mutex, // so callers can use the captured local value through the rest of their // method without racing a concurrent Close. The lock is released on diff --git a/internal/runtime/docker_test.go b/internal/runtime/docker_test.go index 768b086..dca4c37 100644 --- a/internal/runtime/docker_test.go +++ b/internal/runtime/docker_test.go @@ -622,7 +622,7 @@ func TestDockerRuntime_ExecPassesCwdEnvAndCommand(t *testing.T) { } } -func TestDockerRuntime_ExecExpandsPATHInCommand(t *testing.T) { +func TestDockerRuntime_ExecPassesEnvLiterallyIncludingDollar(t *testing.T) { t.Parallel() script := append(createScript("abc"), scriptedCall{match: "exec", response: fakeDockerResponse{stdout: "ok", exitCode: 0}}, @@ -632,10 +632,15 @@ func TestDockerRuntime_ExecExpandsPATHInCommand(t *testing.T) { if err := r.Create(context.Background()); err != nil { t.Fatalf("Create: %v", err) } + // All env values are forwarded literally — including $-bearing ones + // and $(...) command-substitution forms. The runtime no longer + // special-cases PATH (or rejects anything); callers that need shell + // expansion must resolve the value first (see agent.probeAndMergePATH). _, err := r.Exec(context.Background(), "echo hi", ExecOptions{ Env: map[string]string{ - "PATH": "$HOME/.local/bin:$PATH", - "API_KEY": "secret", + "PATH": "$HOME/.local/bin:$PATH", + "API_KEY": "secret", + "WEIRD_VAR": "$(touch /tmp/pwn):$PATH", }, }) if err != nil { @@ -643,35 +648,18 @@ func TestDockerRuntime_ExecExpandsPATHInCommand(t *testing.T) { } args := fd.callArgs(createCallCount) joined := strings.Join(args, " ") - if strings.Contains(joined, "--env PATH=") { - t.Errorf("PATH with $ should NOT be passed via --env; got %v", args) - } - if !strings.Contains(joined, "--env API_KEY=secret") { - t.Errorf("expected --env API_KEY=secret; got %v", args) + for _, want := range []string{ + "--env PATH=$HOME/.local/bin:$PATH", + "--env API_KEY=secret", + "--env WEIRD_VAR=$(touch /tmp/pwn):$PATH", + } { + if !strings.Contains(joined, want) { + t.Errorf("expected %q in args; got %v", want, args) + } } cmd := args[len(args)-1] - if !strings.HasPrefix(cmd, "export PATH=\"$HOME/.local/bin:$PATH\"\n") { - t.Errorf("expected PATH export prepended to command; got command: %q", cmd) - } - if !strings.HasSuffix(cmd, "echo hi") { - t.Errorf("expected original command at end; got command: %q", cmd) - } -} - -func TestDockerRuntime_ExecRejectsCommandSubstitutionInPATH(t *testing.T) { - t.Parallel() - fd := newFakeDocker(t, createScript("abc")) - r := newDockerRuntimeForTest(t, Config{Image: "alpine:3.20"}, fd) - if err := r.Create(context.Background()); err != nil { - t.Fatalf("Create: %v", err) - } - _, err := r.Exec(context.Background(), "echo hi", ExecOptions{ - Env: map[string]string{ - "PATH": "$(touch /tmp/pwn):$PATH", - }, - }) - if err == nil || !strings.Contains(err.Error(), "command substitution") { - t.Fatalf("Exec with PATH=$(...) should fail with command-substitution error, got %v", err) + if cmd != "echo hi" { + t.Errorf("command should be forwarded unchanged; got %q", cmd) } } @@ -1013,3 +1001,26 @@ func TestOverlayEnvList_CallEnvWins(t *testing.T) { // Compile-time check: DockerRuntime satisfies Runtime. var _ Runtime = (*DockerRuntime)(nil) + +func TestDockerRuntime_MergeEnv_AppliesToSubsequentExec(t *testing.T) { + t.Parallel() + script := append(createScript("abc"), + scriptedCall{match: "exec", response: fakeDockerResponse{stdout: "ok"}}, + ) + fd := newFakeDocker(t, script) + r := newDockerRuntimeForTest(t, Config{Image: "alpine:3.20"}, fd) + if err := r.Create(context.Background()); err != nil { + t.Fatalf("Create: %v", err) + } + + r.MergeEnv(map[string]string{"FROM_MERGE": "1"}) + + if _, err := r.Exec(context.Background(), "true", ExecOptions{}); err != nil { + t.Fatalf("Exec: %v", err) + } + + args := fd.callArgs(createCallCount) + if !strings.Contains(strings.Join(args, " "), "--env FROM_MERGE=1") { + t.Errorf("post-MergeEnv exec missing --env FROM_MERGE=1; got %v", args) + } +} diff --git a/internal/runtime/none.go b/internal/runtime/none.go index caf498e..c4263bb 100644 --- a/internal/runtime/none.go +++ b/internal/runtime/none.go @@ -37,6 +37,13 @@ type NoneRuntime struct { workspace string } +// MergeEnv layers entries into the runtime's persistent env baseline. See +// Runtime.MergeEnv for the contract (callers MUST sequence MergeEnv before +// any concurrent Exec; the intended use is a one-time setup step). +func (r *NoneRuntime) MergeEnv(env map[string]string) { + mergeIntoEnvBaseline(&r.cfg.Env, env) +} + // Create allocates the temporary workspace used by the runtime. func (r *NoneRuntime) Create(ctx context.Context) error { dir, err := os.MkdirTemp("", "skill-up-*") diff --git a/internal/runtime/none_test.go b/internal/runtime/none_test.go index 03d856b..a9e06f3 100644 --- a/internal/runtime/none_test.go +++ b/internal/runtime/none_test.go @@ -5,7 +5,6 @@ import ( "context" "errors" "os" - "os/exec" "path/filepath" "strings" "sync" @@ -403,18 +402,21 @@ func TestNoneRuntime_ExecWithEnv(t *testing.T) { } } -func TestNoneRuntime_ExecExpandsPathFromRuntimeEnv(t *testing.T) { - bashPath, err := exec.LookPath("bash") - if err != nil { - t.Fatal(err) - } - basePath := filepath.Dir(bashPath) - t.Setenv("PATH", basePath) - +// TestNoneRuntime_ForwardsEnvLiterally verifies that NoneRuntime forwards +// $-bearing env values literally — matching the docker and opensandbox +// runtimes. Callers that want shell expansion must resolve the value first +// (see internal/agent.probeAndMergePATH) or prepend `export X=...` to the +// command themselves. The runtime never expands. +// +// printf '%s' "$VAR" prints whatever string the runtime handed to the +// shell. If the runtime pre-expanded $CUSTOM_BIN / $PATH the child would +// see "/agent/bin:..." instead of the literal "$CUSTOM_BIN:$PATH". +func TestNoneRuntime_ForwardsEnvLiterally(t *testing.T) { rt := &NoneRuntime{cfg: Config{ Env: map[string]string{ - "CUSTOM_BIN": "/agent/bin", - "PATH": "$CUSTOM_BIN:$PATH", + "CUSTOM_BIN": "/agent/bin", + "LITERAL_PATH": "$CUSTOM_BIN:$PATH", + "ALSO_UNEXPANDED": "${HOME}/foo", }, }} if err := rt.Create(context.Background()); err != nil { @@ -422,12 +424,13 @@ func TestNoneRuntime_ExecExpandsPathFromRuntimeEnv(t *testing.T) { } defer func() { _ = rt.Close() }() - result, err := rt.Exec(context.Background(), "printf %s \"$PATH\"", ExecOptions{}) + result, err := rt.Exec(context.Background(), `printf '%s\n%s\n' "$LITERAL_PATH" "$ALSO_UNEXPANDED"`, ExecOptions{}) if err != nil { t.Fatalf("Exec returned error: %v", err) } - if got, want := result.Stdout, "/agent/bin:"+basePath; got != want { - t.Fatalf("PATH = %q, want %q", got, want) + want := "$CUSTOM_BIN:$PATH\n${HOME}/foo\n" + if result.Stdout != want { + t.Fatalf("env values should be passed literally; got %q want %q", result.Stdout, want) } } @@ -579,3 +582,51 @@ func TestMaskCommand(t *testing.T) { }) } } + +func TestNoneRuntime_MergeEnv(t *testing.T) { + t.Parallel() + rt := &NoneRuntime{} + rt.MergeEnv(map[string]string{"FOO": "1", "BAR": "2"}) + if got := rt.cfg.Env["FOO"]; got != "1" { + t.Fatalf("FOO = %q, want 1", got) + } + if got := rt.cfg.Env["BAR"]; got != "2" { + t.Fatalf("BAR = %q, want 2", got) + } + // Second call overlays; later keys win. + rt.MergeEnv(map[string]string{"BAR": "overridden", "BAZ": "3"}) + if got := rt.cfg.Env["BAR"]; got != "overridden" { + t.Fatalf("BAR after overlay = %q, want overridden", got) + } + if got := rt.cfg.Env["BAZ"]; got != "3" { + t.Fatalf("BAZ = %q, want 3", got) + } + if got := rt.cfg.Env["FOO"]; got != "1" { + t.Fatalf("FOO after overlay = %q, want 1 preserved", got) + } + // Empty map is a no-op. + rt.MergeEnv(nil) + rt.MergeEnv(map[string]string{}) + if len(rt.cfg.Env) != 3 { + t.Fatalf("env length = %d, want 3 after no-op calls", len(rt.cfg.Env)) + } +} + +func TestNoneRuntime_MergeEnv_VisibleToSubsequentExec(t *testing.T) { + t.Parallel() + rt := &NoneRuntime{} + if err := rt.Create(context.Background()); err != nil { + t.Fatal(err) + } + defer func() { _ = rt.Close() }() + + rt.MergeEnv(map[string]string{"SKILL_UP_MERGE_TEST": "visible"}) + + res, err := rt.Exec(context.Background(), `printf '%s' "$SKILL_UP_MERGE_TEST"`, ExecOptions{}) + if err != nil { + t.Fatalf("Exec: %v", err) + } + if res.Stdout != "visible" { + t.Fatalf("Exec saw SKILL_UP_MERGE_TEST=%q, want visible", res.Stdout) + } +} diff --git a/internal/runtime/opensandbox.go b/internal/runtime/opensandbox.go index 9f13e5c..83923eb 100644 --- a/internal/runtime/opensandbox.go +++ b/internal/runtime/opensandbox.go @@ -488,10 +488,10 @@ func (r *OpenSandboxRuntime) Exec(ctx context.Context, command string, opts Exec env := mergeEnvMaps(r.cfg.Env, opts.Env) req := opensandbox.RunCommandRequest{ - Command: withRemoteEnvExpansion(command, env), + Command: command, Cwd: r.execCwd(opts.Cwd), Timeout: int64(opts.TimeoutSec) * 1000, - Envs: literalRemoteEnv(env), + Envs: env, } span.SetAttributes( attribute.String("process.command", command), @@ -541,33 +541,6 @@ func (r *OpenSandboxRuntime) Exec(ctx context.Context, command string, opts Exec return result, nil } -func literalRemoteEnv(env map[string]string) map[string]string { - if len(env) == 0 { - return nil - } - literal := make(map[string]string, len(env)) - for k, v := range env { - if k == "PATH" && strings.Contains(v, "$") { - continue - } - literal[k] = v - } - return literal -} - -func withRemoteEnvExpansion(command string, env map[string]string) string { - pathValue, ok := env["PATH"] - if !ok || !strings.Contains(pathValue, "$") { - return command - } - return "export PATH=" + shellDoubleQuote(pathValue) + "\n" + command -} - -func shellDoubleQuote(s string) string { - replacer := strings.NewReplacer(`\`, `\\`, `"`, `\"`, "`", "\\`") - return `"` + replacer.Replace(s) + `"` -} - // Workspace returns the sandbox workspace path. func (r *OpenSandboxRuntime) Workspace() string { return r.workspace @@ -578,6 +551,12 @@ func (r *OpenSandboxRuntime) RequiresProcessSandbox() bool { return false } +// MergeEnv layers entries into the runtime's persistent env baseline. See +// Runtime.MergeEnv for the contract. +func (r *OpenSandboxRuntime) MergeEnv(env map[string]string) { + mergeIntoEnvBaseline(&r.cfg.Env, env) +} + func (r *OpenSandboxRuntime) connectionConfig() opensandbox.ConnectionConfig { return opensandbox.ConnectionConfig{ Domain: r.baseURL, diff --git a/internal/runtime/opensandbox_test.go b/internal/runtime/opensandbox_test.go index 79183b0..83fcada 100644 --- a/internal/runtime/opensandbox_test.go +++ b/internal/runtime/opensandbox_test.go @@ -277,33 +277,6 @@ func TestOpenSandboxLocalHelpersRejectUnsafePathsAndPreserveRemoteScope(t *testi } } -func TestOpenSandboxCommandHelpers(t *testing.T) { - t.Parallel() - - env := map[string]string{ - "PATH": "$PATH:/custom/bin", - "PLAIN": "value", - } - literal := literalRemoteEnv(env) - if _, ok := literal["PATH"]; ok { - t.Fatalf("literalRemoteEnv kept expandable PATH: %v", literal) - } - if literal["PLAIN"] != "value" { - t.Fatalf("literalRemoteEnv dropped PLAIN: %v", literal) - } - - command := withRemoteEnvExpansion("echo ok", env) - if !strings.HasPrefix(command, `export PATH="$PATH:/custom/bin"`+"\n") { - t.Fatalf("withRemoteEnvExpansion = %q", command) - } - if got := withRemoteEnvExpansion("echo ok", map[string]string{"PATH": "/usr/bin"}); got != "echo ok" { - t.Fatalf("withRemoteEnvExpansion without expansion = %q, want original command", got) - } - if got := shellDoubleQuote(`a"b\c` + "`d"); got != `"a\"b\\c\`+"`"+`d"` { - t.Fatalf("shellDoubleQuote returned %q", got) - } -} - func TestOpenSandboxExecutionToResult(t *testing.T) { t.Parallel() @@ -725,11 +698,14 @@ func TestOpenSandboxExecMapsOptionsAndResult(t *testing.T) { if req.Cwd != "/workspace/repo" || req.Timeout != 5000 || req.Envs["X"] != "Y" || req.Envs["CUSTOM_BIN"] != "/agent/bin" { t.Fatalf("unexpected exec request: %+v", req) } - if _, ok := req.Envs["PATH"]; ok { - t.Fatalf("PATH should be expanded remotely instead of passed literally: %+v", req.Envs) + // Env values forward literally — including $-bearing PATH. Callers + // that need shell expansion must resolve the value first (see + // internal/agent.probeAndMergePATH). + if got := req.Envs["PATH"]; got != "$CUSTOM_BIN:$PATH" { + t.Fatalf("PATH should forward literally; got %q in %+v", got, req.Envs) } - if !strings.Contains(req.Command, `export PATH="$CUSTOM_BIN:$PATH"`) || !strings.Contains(req.Command, "echo hello") { - t.Fatalf("unexpected command with env expansion: %q", req.Command) + if req.Command != "echo hello" { + t.Fatalf("command should forward unchanged; got %q", req.Command) } } @@ -1090,3 +1066,21 @@ func (f *fakeOpenSandbox) RunCommandWithOpts(_ context.Context, req opensandbox. func intPtr(v int) *int { return &v } + +func TestOpenSandboxRuntime_MergeEnv_AppliesToSubsequentExec(t *testing.T) { + rt := &OpenSandboxRuntime{ + workspace: "/workspace", + sandbox: &fakeOpenSandbox{ + execResult: &opensandbox.Execution{ExitCode: intPtr(0)}, + }, + } + rt.MergeEnv(map[string]string{"FROM_MERGE": "yes"}) + + if _, err := rt.Exec(context.Background(), "true", ExecOptions{}); err != nil { + t.Fatalf("Exec: %v", err) + } + fake, _ := rt.sandbox.(*fakeOpenSandbox) + if got := fake.lastExec.Envs["FROM_MERGE"]; got != "yes" { + t.Fatalf("Envs[FROM_MERGE] = %q, want yes; got envs=%+v", got, fake.lastExec.Envs) + } +} diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index 5042a0c..e86352c 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -17,17 +17,15 @@ const ( ClaudeFileMode = 0o600 ) +// mergeEnv returns the host env (os.Environ) with persistentEnv and callEnv +// overlaid, in that order (callEnv wins). Values are forwarded LITERALLY: no +// $VAR / ${VAR} expansion. This matches the docker and opensandbox runtimes, +// which also pass env literally — callers that need shell expansion should +// either resolve the value first or prepend `export X=...` to the command. func mergeEnv(persistentEnv, callEnv map[string]string) []string { - baseEnv := envMapFromList(os.Environ()) - envMap := expandEnvMap(baseEnv, mergeEnvMaps(persistentEnv, callEnv)) - if envMap == nil { - envMap = baseEnv - } - for k, v := range baseEnv { - if _, ok := envMap[k]; !ok { - envMap[k] = v - } - } + envMap := envMapFromList(os.Environ()) + maps.Copy(envMap, persistentEnv) + maps.Copy(envMap, callEnv) env := make([]string, 0, len(envMap)) for k, v := range envMap { @@ -36,25 +34,6 @@ func mergeEnv(persistentEnv, callEnv map[string]string) []string { return env } -func expandEnvMap(baseEnv, overlay map[string]string) map[string]string { - if len(overlay) == 0 { - return nil - } - expanded := make(map[string]string, len(overlay)) - for key, value := range overlay { - expanded[key] = os.Expand(value, func(name string) string { - if name == key { - return baseEnv[name] - } - if overlayValue, ok := overlay[name]; ok { - return overlayValue - } - return baseEnv[name] - }) - } - return expanded -} - func envMapFromList(env []string) map[string]string { envMap := make(map[string]string, len(env)) for _, item := range env { @@ -76,6 +55,20 @@ func mergeEnvMaps(persistentEnv, callEnv map[string]string) map[string]string { return env } +// mergeIntoEnvBaseline overlays src onto *target. Single shared +// implementation for Runtime.MergeEnv across the three concrete runtimes; +// they each retain a 1-line method so they continue to satisfy the +// interface, but the behaviour itself lives here. +func mergeIntoEnvBaseline(target *map[string]string, src map[string]string) { + if len(src) == 0 { + return + } + if *target == nil { + *target = make(map[string]string, len(src)) + } + maps.Copy(*target, src) +} + // ExecResult holds the output and exit code of a command execution. type ExecResult struct { Stdout string @@ -109,6 +102,13 @@ type Runtime interface { DownloadDir(ctx context.Context, sourceDir, targetDir string) error Exec(ctx context.Context, command string, opts ExecOptions) (ExecResult, error) + // MergeEnv layers entries onto the runtime's persistent env baseline + // (Config.Env). Subsequent Exec calls see these vars unless overridden + // by opts.Env. Used by orchestrators (e.g. the evaluator) to seed + // runtime-resolved values — for example the agent's PATH expanded + // against the target shell — without each Exec caller needing to + // know about them. Idempotent; later calls overwrite same-key values. + MergeEnv(env map[string]string) Workspace() string // RequiresProcessSandbox reports whether agents should enable their own process sandbox. RequiresProcessSandbox() bool