Skip to content
Merged
35 changes: 35 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 6 additions & 6 deletions e2e/pipeline_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
24 changes: 22 additions & 2 deletions internal/agent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -258,14 +257,35 @@ 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))
opts.Env = merged
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 {
Expand Down
79 changes: 77 additions & 2 deletions internal/agent/agent_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package agent
import (
"context"
"errors"
"maps"
"os"
"path/filepath"
"strings"
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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()

Expand Down
10 changes: 10 additions & 0 deletions internal/agent/claude_code.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 == "" {
Expand All @@ -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)

Expand Down
39 changes: 32 additions & 7 deletions internal/agent/claude_code_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package agent
import (
"context"
"errors"
"maps"
"os"
"path/filepath"
"strings"
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 }
Expand All @@ -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 {
Expand All @@ -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)
}
9 changes: 9 additions & 0 deletions internal/agent/codex.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 == "" {
Expand All @@ -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)

Expand Down
Loading
Loading