From f4599b896e5375b91b64b280dae2eeb2967dc52c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=A7=90=E6=9E=9D?= Date: Tue, 26 May 2026 20:05:50 +0800 Subject: [PATCH 1/8] feat(runtime): add MergeEnv to Runtime interface for post-Create env updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lets setup-time orchestrators (notably each agent's Install) seed the runtime's persistent env baseline with values resolved through the target shell. Subsequent Exec calls inherit these vars unless overridden by opts.Env. The intended use case is one-time setup before the runtime is exercised in parallel — concurrent MergeEnv vs. Exec is not safe and documented as such. The three real runtimes (NoneRuntime, DockerRuntime, OpenSandboxRuntime) implement MergeEnv as a simple cfg.Env overlay. For DockerRuntime, the container's entrypoint (sleep infinity) is started with the env present at Create time, so post-Create MergeEnv only affects subsequent `docker exec` calls — the entrypoint runs unaffected. Six in-tree test mocks (mockRuntime, scriptJudgeRuntime, mockJudgeTestRuntime, claudeCodeTestRuntime, codexTestRuntime, qoderTestRuntime) gain trivial no-op stubs to keep implementing the interface; tests that need to observe MergeEnv behaviour will get real recording in later commits. No caller changes — this commit only widens the interface and adds the plumbing. Tests for the new behaviour live next to each real runtime. Co-Authored-By: Claude Opus 4.7 --- internal/agent/claude_code_test.go | 1 + internal/agent/codex_test.go | 1 + internal/agent/qodercli_test.go | 1 + internal/evaluator/evaluator_test.go | 1 + internal/judge/helpers_test.go | 1 + internal/judge/script_test.go | 1 + internal/runtime/docker.go | 16 ++++++++++ internal/runtime/docker_test.go | 23 +++++++++++++ internal/runtime/none.go | 14 ++++++++ internal/runtime/none_test.go | 48 ++++++++++++++++++++++++++++ internal/runtime/opensandbox.go | 13 ++++++++ internal/runtime/opensandbox_test.go | 18 +++++++++++ internal/runtime/runtime.go | 7 ++++ 13 files changed, 145 insertions(+) diff --git a/internal/agent/claude_code_test.go b/internal/agent/claude_code_test.go index 72c93dd..2ea7141 100644 --- a/internal/agent/claude_code_test.go +++ b/internal/agent/claude_code_test.go @@ -741,3 +741,4 @@ func (r *claudeCodeTestRuntime) Workspace() string { return r.workspace } func (r *claudeCodeTestRuntime) RequiresProcessSandbox() bool { return true } +func (r *claudeCodeTestRuntime) MergeEnv(_ map[string]string) {} diff --git a/internal/agent/codex_test.go b/internal/agent/codex_test.go index 49053e2..9d06ebe 100644 --- a/internal/agent/codex_test.go +++ b/internal/agent/codex_test.go @@ -1006,3 +1006,4 @@ func (r *codexTestRuntime) Workspace() string { return r.workspace } func (r *codexTestRuntime) RequiresProcessSandbox() bool { return r.workspace != "opensandbox" } +func (r *codexTestRuntime) MergeEnv(_ map[string]string) {} diff --git a/internal/agent/qodercli_test.go b/internal/agent/qodercli_test.go index 7294163..716e0b4 100644 --- a/internal/agent/qodercli_test.go +++ b/internal/agent/qodercli_test.go @@ -397,3 +397,4 @@ func (r *qoderTestRuntime) Workspace() string { return r.workspace } func (r *qoderTestRuntime) RequiresProcessSandbox() bool { return true } +func (r *qoderTestRuntime) MergeEnv(_ map[string]string) {} 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..193b9d9 100644 --- a/internal/runtime/docker.go +++ b/internal/runtime/docker.go @@ -7,6 +7,7 @@ import ( "encoding/hex" "errors" "fmt" + "maps" "os" "os/exec" "path" @@ -514,6 +515,21 @@ 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) { + if len(env) == 0 { + return + } + if r.cfg.Env == nil { + r.cfg.Env = make(map[string]string, len(env)) + } + maps.Copy(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..ba63929 100644 --- a/internal/runtime/docker_test.go +++ b/internal/runtime/docker_test.go @@ -1013,3 +1013,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..bd25ab5 100644 --- a/internal/runtime/none.go +++ b/internal/runtime/none.go @@ -5,6 +5,7 @@ import ( "context" "errors" "fmt" + "maps" "os" "os/exec" "path/filepath" @@ -37,6 +38,19 @@ 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) { + if len(env) == 0 { + return + } + if r.cfg.Env == nil { + r.cfg.Env = make(map[string]string, len(env)) + } + maps.Copy(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..dd9fca4 100644 --- a/internal/runtime/none_test.go +++ b/internal/runtime/none_test.go @@ -579,3 +579,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..52f47e5 100644 --- a/internal/runtime/opensandbox.go +++ b/internal/runtime/opensandbox.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "io" + "maps" "os" "path" "path/filepath" @@ -578,6 +579,18 @@ 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) { + if len(env) == 0 { + return + } + if r.cfg.Env == nil { + r.cfg.Env = make(map[string]string, len(env)) + } + maps.Copy(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..8232ae6 100644 --- a/internal/runtime/opensandbox_test.go +++ b/internal/runtime/opensandbox_test.go @@ -1090,3 +1090,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..1b43701 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -109,6 +109,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 From 26af5e8097f71c4d86fdb64608329467ffb689e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=A7=90=E6=9E=9D?= Date: Tue, 26 May 2026 20:17:27 +0800 Subject: [PATCH 2/8] refactor(agent): probe runtime PATH per agent at Install and merge via MergeEnv MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move PATH provisioning from "agent injects $-string into env map and hopes the runtime expands it" to "agent asks the runtime's own shell to expand the string, then writes the literal result to the runtime's env baseline". Concretely: - agent.go: `mergeExecOptionsEnv` no longer injects PATH; it now only merges credentials, observability, and caller env. The `agentExecutablePath` constant is gone — it was the shell-expansion string that all the runtime-side compensation code existed to handle. - agent.go: new private helper `probeAndMergePATH(ctx, rt, probeCmd)` runs the supplied probe (a `printf '%s' "$HOME/..."`-style command) through the runtime, then calls `rt.MergeEnv({"PATH": })`. Probe failure logs a warning and leaves the runtime's default PATH in effect. - Each agent's `Install` calls the helper at the top with its own per-agent probe constant: - claude-code & codex: include $HOME/.local/bin AND $HOME/.nvm/current/bin (their CLIs are node scripts whose shebang needs node, which lives under the nvm symlink). - qodercli: $HOME/.local/bin only (qodercli is a self-contained binary placed by its official installer; no nvm needed). The probe constants are deliberately not shared even where claude and codex have identical values — a future divergence shouldn't silently cross-contaminate. Type=none semantic change: the evaluator skips `ag.Install` when environment.type=none, so the probe doesn't run there either. The host shell's PATH is now the contract for type=none, matching the Unix expectation that tools inherit your shell env. This drops the previously-forced `$HOME/.local/bin:$HOME/.nvm/current/bin:$PATH` for type=none users; documented in a follow-up commit's CHANGELOG. Tests: agent_test.go gains three TestProbeAndMergePATH_* cases for happy/failure/empty-stdout paths. The three per-agent install tests have their `lastExecEnv["PATH"] == agentExecutablePath` assertion replaced with the new shape: PATH absent from opts.Env (agent doesn't inject) AND `mergedEnv["PATH"]` populated (probe→MergeEnv ran). The three fake runtimes (claudeCodeTestRuntime, codexTestRuntime, qoderTestRuntime) gain a probeResponseStdout field, a recording MergeEnv impl, and a guard that detects the probe command shape so it isn't recorded as the agent's "real" command. e2e/pipeline_test.go comment updated to describe the new mechanism (and to note why the mock-engine path is unaffected: it uses type=none with explicit mockEngineEnv-supplied PATH). Co-Authored-By: Claude Opus 4.7 --- e2e/pipeline_test.go | 12 ++--- internal/agent/agent.go | 24 ++++++++- internal/agent/agent_test.go | 79 +++++++++++++++++++++++++++++- internal/agent/claude_code.go | 8 +++ internal/agent/claude_code_test.go | 37 +++++++++++--- internal/agent/codex.go | 7 +++ internal/agent/codex_test.go | 44 ++++++++++++----- internal/agent/qodercli.go | 7 +++ internal/agent/qodercli_test.go | 34 ++++++++++--- 9 files changed, 215 insertions(+), 37 deletions(-) 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..b710e14 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 == "" { @@ -41,6 +47,8 @@ func NewClaudeCodeAgent(cfg Config) *ClaudeCodeAgent { // Install installs Claude Code when it is not already available in the runtime. 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 2ea7141..a93fa7d 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,16 @@ 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 (printf '%s' "$HOME/...") are issued by agent.Install + // via probeAndMergePATH; respond with a canned literal PATH and do + // NOT record as the agent's own command. + if strings.HasPrefix(command, `printf '%s' "$HOME/`) { + 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,4 +757,9 @@ func (r *claudeCodeTestRuntime) Workspace() string { return r.workspace } func (r *claudeCodeTestRuntime) RequiresProcessSandbox() bool { return true } -func (r *claudeCodeTestRuntime) MergeEnv(_ map[string]string) {} +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..a0446ff 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 == "" { @@ -68,6 +73,8 @@ func NewCodexAgent(cfg Config) *CodexAgent { // Install installs Codex CLI when it is not already available in the runtime. 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 9d06ebe..3e4214c 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,15 @@ 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) — respond with a + // canned literal PATH; do not record as a real command. + if strings.HasPrefix(command, `printf '%s' "$HOME/`) { + 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,4 +1021,9 @@ func (r *codexTestRuntime) Workspace() string { return r.workspace } func (r *codexTestRuntime) RequiresProcessSandbox() bool { return r.workspace != "opensandbox" } -func (r *codexTestRuntime) MergeEnv(_ map[string]string) {} +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..3d748ff 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 == "" { @@ -186,6 +191,8 @@ func findQoderSessionFile(ctx context.Context, rt Runtime) string { // Install installs qoder CLI via official install script. 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 716e0b4..4145833 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,15 @@ 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) — respond with a + // canned literal PATH; do not record as a real command. + if strings.HasPrefix(command, `printf '%s' "$HOME/`) { + 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,4 +412,9 @@ func (r *qoderTestRuntime) Workspace() string { return r.workspace } func (r *qoderTestRuntime) RequiresProcessSandbox() bool { return true } -func (r *qoderTestRuntime) MergeEnv(_ map[string]string) {} +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) +} From ad8e519a4433034eaa052236e608424344e18aee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=A7=90=E6=9E=9D?= Date: Tue, 26 May 2026 20:21:23 +0800 Subject: [PATCH 3/8] refactor(runtime): drop PATH-with-$ special-case in docker/opensandbox Exec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both runtimes carried matching hacks to detect PATH values containing \$, strip them from the literal env list, and prepend `export PATH=...` to the command so the inner shell would expand them. Docker also rejected \$(...) to guard against command substitution firing inside that auto-generated export. The agent layer no longer pushes \$-bearing PATH (see prior commit: each agent's Install probes the runtime shell for the literal PATH and hands it to runtime.MergeEnv). With no caller pushing such values, the runtime can pass every env key literally — no key special-cased, no \$(...) rejection needed because the runtime never constructs a shell command from a caller-supplied env value. Removes: - docker.go: PATH-with-\$ skip in buildCreateArgs and the pathPrefix block in Exec (including the \$(...) rejection and the comment that explained why it was needed). - opensandbox.go: literalRemoteEnv, withRemoteEnvExpansion, and the shellDoubleQuote helper they shared. opensandbox Exec now just passes Command + Envs straight through to the SDK. Tests updated to assert literal pass-through (including \$-bearing PATH and \$(...) forms) rather than the deleted special-case behaviour. Co-Authored-By: Claude Opus 4.7 --- internal/runtime/docker.go | 35 ++++---------------- internal/runtime/docker_test.go | 48 +++++++++++----------------- internal/runtime/opensandbox.go | 31 ++---------------- internal/runtime/opensandbox_test.go | 13 +++++--- 4 files changed, 35 insertions(+), 92 deletions(-) diff --git a/internal/runtime/docker.go b/internal/runtime/docker.go index 193b9d9..5fc910a 100644 --- a/internal/runtime/docker.go +++ b/internal/runtime/docker.go @@ -166,9 +166,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 @@ -411,31 +408,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 @@ -448,7 +427,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), diff --git a/internal/runtime/docker_test.go b/internal/runtime/docker_test.go index ba63929..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) } } diff --git a/internal/runtime/opensandbox.go b/internal/runtime/opensandbox.go index 52f47e5..9c555ba 100644 --- a/internal/runtime/opensandbox.go +++ b/internal/runtime/opensandbox.go @@ -489,10 +489,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), @@ -542,33 +542,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 diff --git a/internal/runtime/opensandbox_test.go b/internal/runtime/opensandbox_test.go index 8232ae6..2958913 100644 --- a/internal/runtime/opensandbox_test.go +++ b/internal/runtime/opensandbox_test.go @@ -725,11 +725,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) - } - 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) + // 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 req.Command != "echo hello" { + t.Fatalf("command should forward unchanged; got %q", req.Command) } } From 050619bc2fcb3cb22461bccbfd0c3a7fa2c05d0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=A7=90=E6=9E=9D?= Date: Tue, 26 May 2026 20:24:56 +0800 Subject: [PATCH 4/8] refactor(runtime): drop NoneRuntime env expansion for consistency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mergeEnv previously routed values through expandEnvMap, which used os.Expand to substitute \$VAR / \${VAR} against the host env before handing the result to cmd.Env. Docker and opensandbox have always forwarded values literally, so the same eval.yaml could behave differently under different runtimes. Drop expandEnvMap and forward values literally in NoneRuntime as well. Callers that need shell-side expansion should resolve the value first (see internal/agent.probeAndMergePATH) or prepend `export X=...` to their command. CHANGELOG documents the migration. Tests: TestNoneRuntime_ExecExpandsPathFromRuntimeEnv replaced with TestNoneRuntime_ForwardsEnvLiterally, which asserts the inverse invariant — \$-bearing values reach the child process as the literal string, with no host-side substitution. CHANGELOG: [Unreleased] section now lists the three concrete changes shipped by this PR — `runtime.MergeEnv` added, agent layer no longer injects PATH, NoneRuntime stops expanding `\$VAR`. Includes the type=none migration note about the host shell's PATH now being the contract. Co-Authored-By: Claude Opus 4.7 --- CHANGELOG.md | 26 ++++++++++++++++++++++++ internal/runtime/none_test.go | 31 ++++++++++++++++------------- internal/runtime/runtime.go | 37 ++++++++--------------------------- 3 files changed, 51 insertions(+), 43 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d1de89..c736ec0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,32 @@ 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. +- `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/internal/runtime/none_test.go b/internal/runtime/none_test.go index dd9fca4..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) } } diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index 1b43701..c9c2e19 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 { From d30bb39606484b66820611e861d4245354d17dca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=A7=90=E6=9E=9D?= Date: Tue, 26 May 2026 20:36:39 +0800 Subject: [PATCH 5/8] chore: enumerate docker/opensandbox PATH-with-$ regression; tighten test probe match MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two follow-ups from PR review: 1. CHANGELOG was silent on the docker/opensandbox side of the "values forward literally" rule. The doc only called out NoneRuntime dropping host-side $VAR expansion, but the same PR also stops silently dropping `environment.env.PATH` values containing `$` in docker (previously buildCreateArgs skipped such keys to keep the container's entrypoint executable). Repo grep finds zero users relying on this — phantom scenario, deliberately accepted in PR discussion — but the breakage mode (container fails to start) is loud enough that users hitting it deserve to see the migration note alongside the NoneRuntime one. 2. The three agent test fakes used `strings.HasPrefix(command, "printf '%s' \"$HOME/")` to detect probe Exec calls. Loose prefix match would silently swallow any future test that legitimately issued such a command for non-probe reasons, returning the canned fake PATH instead of recording the real exec. Tighten to exact match against each agent's known probe constant (claudeCodeExecPathProbeCmd / codexExecPathProbeCmd / qoderExecPathProbeCmd). Production code never had this issue — real runtimes don't dispatch on command shape — so this is purely a test-isolation hardening. Co-Authored-By: Claude Opus 4.7 --- CHANGELOG.md | 9 +++++++++ internal/agent/claude_code_test.go | 10 ++++++---- internal/agent/codex_test.go | 8 +++++--- internal/agent/qodercli_test.go | 8 +++++--- 4 files changed, 25 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c736ec0..feb6429 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 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 diff --git a/internal/agent/claude_code_test.go b/internal/agent/claude_code_test.go index a93fa7d..8b24fb0 100644 --- a/internal/agent/claude_code_test.go +++ b/internal/agent/claude_code_test.go @@ -734,10 +734,12 @@ 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 (printf '%s' "$HOME/...") are issued by agent.Install - // via probeAndMergePATH; respond with a canned literal PATH and do - // NOT record as the agent's own command. - if strings.HasPrefix(command, `printf '%s' "$HOME/`) { + // 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" diff --git a/internal/agent/codex_test.go b/internal/agent/codex_test.go index 3e4214c..c3ede6e 100644 --- a/internal/agent/codex_test.go +++ b/internal/agent/codex_test.go @@ -988,9 +988,11 @@ 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) — respond with a - // canned literal PATH; do not record as a real command. - if strings.HasPrefix(command, `printf '%s' "$HOME/`) { + // 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" diff --git a/internal/agent/qodercli_test.go b/internal/agent/qodercli_test.go index 4145833..58c6834 100644 --- a/internal/agent/qodercli_test.go +++ b/internal/agent/qodercli_test.go @@ -391,9 +391,11 @@ 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) — respond with a - // canned literal PATH; do not record as a real command. - if strings.HasPrefix(command, `printf '%s' "$HOME/`) { + // 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" From 9b779e1a2374142edd36ea939d6e0f7d38ea11af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=A7=90=E6=9E=9D?= Date: Wed, 27 May 2026 09:12:54 +0800 Subject: [PATCH 6/8] test(runtime): drop TestOpenSandboxCommandHelpers, dead after PATH cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The earlier "drop PATH-with-$ special-case in docker/opensandbox Exec" commit deleted literalRemoteEnv / withRemoteEnvExpansion / shellDoubleQuote from opensandbox.go but didn't notice that PR #71 (merged into main while this branch was in flight) had just added a TestOpenSandboxCommandHelpers test specifically exercising those three helpers. After rebasing onto the new main the symbols are referenced from a test but defined nowhere — compile fails on every CI job that hits the runtime package. The helpers were intentionally removed (no caller now constructs a shell command from a $-bearing env value, so neither the auto-quote nor the $(...) rejection has anything to guard). Drop the orphaned test. Co-Authored-By: Claude Opus 4.7 --- internal/runtime/opensandbox_test.go | 27 --------------------------- 1 file changed, 27 deletions(-) diff --git a/internal/runtime/opensandbox_test.go b/internal/runtime/opensandbox_test.go index 2958913..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() From d239feec3a386055d47fa82db1be54f402f6fc11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=A7=90=E6=9E=9D?= Date: Wed, 27 May 2026 09:13:34 +0800 Subject: [PATCH 7/8] refactor(runtime): extract shared mergeIntoEnvBaseline used by all MergeEnv impls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three runtimes' MergeEnv methods were byte-identical: if len(env) == 0 { return } if r.cfg.Env == nil { r.cfg.Env = make(map[string]string, len(env)) } maps.Copy(r.cfg.Env, env) Hoist the body into a package-level `mergeIntoEnvBaseline` helper in runtime.go and shrink each runtime's MergeEnv to a one-line delegation. The methods stay (the Runtime interface still demands them, and the docstrings document each runtime's specific caveat — e.g. docker's note that post-Create MergeEnv only affects subsequent `docker exec` calls, not the long-running container entrypoint), but the behaviour lives in exactly one place. Reasoning for delegation over embedding: Go embedding would also work (`type envBaseline struct{...}; MergeEnv(...)` embedded into each concrete runtime), but that would require moving the env state out of cfg.Env into a new field, threading initialization through every construction site (including struct-literal `&NoneRuntime{cfg:...}` patterns in tests), and renaming `r.cfg.Env` reads in every Exec path. The free-helper form gives the same "one implementation" property with zero changes to existing constructors or readers. Co-Authored-By: Claude Opus 4.7 --- internal/runtime/docker.go | 9 +-------- internal/runtime/none.go | 9 +-------- internal/runtime/opensandbox.go | 9 +-------- internal/runtime/runtime.go | 14 ++++++++++++++ 4 files changed, 17 insertions(+), 24 deletions(-) diff --git a/internal/runtime/docker.go b/internal/runtime/docker.go index 5fc910a..445e530 100644 --- a/internal/runtime/docker.go +++ b/internal/runtime/docker.go @@ -7,7 +7,6 @@ import ( "encoding/hex" "errors" "fmt" - "maps" "os" "os/exec" "path" @@ -500,13 +499,7 @@ func (r *DockerRuntime) RequiresProcessSandbox() bool { // post-Create MergeEnv calls only affect subsequent `docker exec` // invocations, not the long-running entrypoint process. func (r *DockerRuntime) MergeEnv(env map[string]string) { - if len(env) == 0 { - return - } - if r.cfg.Env == nil { - r.cfg.Env = make(map[string]string, len(env)) - } - maps.Copy(r.cfg.Env, env) + mergeIntoEnvBaseline(&r.cfg.Env, env) } // snapshotContainerID returns the current container id under the mutex, diff --git a/internal/runtime/none.go b/internal/runtime/none.go index bd25ab5..c4263bb 100644 --- a/internal/runtime/none.go +++ b/internal/runtime/none.go @@ -5,7 +5,6 @@ import ( "context" "errors" "fmt" - "maps" "os" "os/exec" "path/filepath" @@ -42,13 +41,7 @@ type NoneRuntime struct { // 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) { - if len(env) == 0 { - return - } - if r.cfg.Env == nil { - r.cfg.Env = make(map[string]string, len(env)) - } - maps.Copy(r.cfg.Env, env) + mergeIntoEnvBaseline(&r.cfg.Env, env) } // Create allocates the temporary workspace used by the runtime. diff --git a/internal/runtime/opensandbox.go b/internal/runtime/opensandbox.go index 9c555ba..83923eb 100644 --- a/internal/runtime/opensandbox.go +++ b/internal/runtime/opensandbox.go @@ -6,7 +6,6 @@ import ( "errors" "fmt" "io" - "maps" "os" "path" "path/filepath" @@ -555,13 +554,7 @@ func (r *OpenSandboxRuntime) RequiresProcessSandbox() bool { // MergeEnv layers entries into the runtime's persistent env baseline. See // Runtime.MergeEnv for the contract. func (r *OpenSandboxRuntime) MergeEnv(env map[string]string) { - if len(env) == 0 { - return - } - if r.cfg.Env == nil { - r.cfg.Env = make(map[string]string, len(env)) - } - maps.Copy(r.cfg.Env, env) + mergeIntoEnvBaseline(&r.cfg.Env, env) } func (r *OpenSandboxRuntime) connectionConfig() opensandbox.ConnectionConfig { diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index c9c2e19..e86352c 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -55,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 From c80d44d80794832c1537dcb42070ca5d470ccbb1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=A7=90=E6=9E=9D?= Date: Wed, 27 May 2026 09:21:37 +0800 Subject: [PATCH 8/8] chore(agent): satisfy gofumpt and quiet dupl on 3 sibling Install methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI lint surfaced two classes of nits the local incremental lint missed: 1. gofumpt formatting on the trailing close-brace of claude_code_test.go, codex_test.go, and qodercli_test.go. Applied verbatim. 2. dupl flagged the three agent Install methods as near-duplicates of each other (49-68 lines pair-wise). They are intentionally similar: each agent's Install shares the same probe→merge→exec lifecycle and only varies in the per-agent probe constant and default install command builder, which are already pulled out into per-agent constants and functions. Extracting the orchestration into a BaseAgent helper would add an indirection layer that obscures the per-agent control flow without removing real complexity. Match the existing pattern (codex.go:232 / qodercli.go:71 on Run) and pin `//nolint:dupl` with the rationale on each Install. Co-Authored-By: Claude Opus 4.7 --- internal/agent/claude_code.go | 2 ++ internal/agent/claude_code_test.go | 1 + internal/agent/codex.go | 2 ++ internal/agent/codex_test.go | 1 + internal/agent/qodercli.go | 2 ++ internal/agent/qodercli_test.go | 1 + 6 files changed, 9 insertions(+) diff --git a/internal/agent/claude_code.go b/internal/agent/claude_code.go index b710e14..12d1008 100644 --- a/internal/agent/claude_code.go +++ b/internal/agent/claude_code.go @@ -46,6 +46,8 @@ 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) diff --git a/internal/agent/claude_code_test.go b/internal/agent/claude_code_test.go index 8b24fb0..f996a23 100644 --- a/internal/agent/claude_code_test.go +++ b/internal/agent/claude_code_test.go @@ -759,6 +759,7 @@ 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)) diff --git a/internal/agent/codex.go b/internal/agent/codex.go index a0446ff..caa8239 100644 --- a/internal/agent/codex.go +++ b/internal/agent/codex.go @@ -72,6 +72,8 @@ 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) diff --git a/internal/agent/codex_test.go b/internal/agent/codex_test.go index c3ede6e..662b392 100644 --- a/internal/agent/codex_test.go +++ b/internal/agent/codex_test.go @@ -1023,6 +1023,7 @@ 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)) diff --git a/internal/agent/qodercli.go b/internal/agent/qodercli.go index 3d748ff..1fc97e5 100644 --- a/internal/agent/qodercli.go +++ b/internal/agent/qodercli.go @@ -190,6 +190,8 @@ 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) diff --git a/internal/agent/qodercli_test.go b/internal/agent/qodercli_test.go index 58c6834..12d5e47 100644 --- a/internal/agent/qodercli_test.go +++ b/internal/agent/qodercli_test.go @@ -414,6 +414,7 @@ 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))