← back to Cli Printing Press
fix(cli): scope HOME/XDG_CONFIG_HOME for verify/dogfood subprocesses (#1416)
c5a51eadfede71058d5b4b8ab9c0ba7d17a7c82b · 2026-05-14 22:25:56 -0700 · Trevin Chow
* fix(cli): scope HOME/XDG_CONFIG_HOME for verify/dogfood subprocesses
Verify, dogfood, live-dogfood, live-check, and workflow-verify each
build the printed CLI and exec it as a child process with mock-mode
env vars (<API>_BASE_URL pointing at a test server, <API>_TOKEN set to
a placeholder). Until now the child also inherited the operator's
$HOME / $XDG_CONFIG_HOME, so any save path the printed CLI tripped
(auth login under a mock server, doctor repair flows, set-token, ...)
persisted those mock values to ~/.config/<api>-pp-cli/config.<format>,
clobbering the operator's real credentials. Their next real-API
invocation failed with "dial tcp 127.0.0.1:<port>: connection refused".
Each top-level pipeline entry point now installs an ephemeral
subprocess home for the duration of the run and overlays HOME, the
XDG_* triad, and the Windows equivalents (USERPROFILE / APPDATA /
LOCALAPPDATA) onto every child env. Children resolve
os.UserConfigDir / os.UserHomeDir under the tempdir and their writes
land there; the parent's real home is never touched.
The fix is in the pipeline package, not in the generator, so it covers
already-shipped printed CLIs without a regen.
Closes #1409
* fix(cli): guard scoped subprocess home with RWMutex; check test errors
Greptile flagged that scopedHomeDir was an unprotected package-level
global. Production entry points run sequentially, but the pipeline
package's own tests (synthetic_spec_test, dogfood_test, live_dogfood_test)
use t.Parallel(), and go test -race surfaces a real data race when those
tests concurrently invoke RunDogfood / RunVerify.
Add a sync.RWMutex around currentSubprocessHome and installScopedSubprocessHome
so the race detector is clean. Reads are RLock so the hot subprocessEnv()
path stays cheap; writes only happen at session install/restore.
Also fix the two test helpers that ignored scopeSubprocessHome's error
return; on a MkdirTemp failure the downstream assertions would have run
against an empty scoped home and produced a confusing failure mode.
Files touched
M internal/pipeline/dogfood.goM internal/pipeline/live_check.goM internal/pipeline/live_dogfood.goM internal/pipeline/runtime.goM internal/pipeline/runtime_commands.goM internal/pipeline/runtime_structural.goA internal/pipeline/subprocess_env.goA internal/pipeline/subprocess_env_test.goM internal/pipeline/workflow_verify.go
Diff
commit c5a51eadfede71058d5b4b8ab9c0ba7d17a7c82b
Author: Trevin Chow <trevin@trevinchow.com>
Date: Thu May 14 22:25:56 2026 -0700
fix(cli): scope HOME/XDG_CONFIG_HOME for verify/dogfood subprocesses (#1416)
* fix(cli): scope HOME/XDG_CONFIG_HOME for verify/dogfood subprocesses
Verify, dogfood, live-dogfood, live-check, and workflow-verify each
build the printed CLI and exec it as a child process with mock-mode
env vars (<API>_BASE_URL pointing at a test server, <API>_TOKEN set to
a placeholder). Until now the child also inherited the operator's
$HOME / $XDG_CONFIG_HOME, so any save path the printed CLI tripped
(auth login under a mock server, doctor repair flows, set-token, ...)
persisted those mock values to ~/.config/<api>-pp-cli/config.<format>,
clobbering the operator's real credentials. Their next real-API
invocation failed with "dial tcp 127.0.0.1:<port>: connection refused".
Each top-level pipeline entry point now installs an ephemeral
subprocess home for the duration of the run and overlays HOME, the
XDG_* triad, and the Windows equivalents (USERPROFILE / APPDATA /
LOCALAPPDATA) onto every child env. Children resolve
os.UserConfigDir / os.UserHomeDir under the tempdir and their writes
land there; the parent's real home is never touched.
The fix is in the pipeline package, not in the generator, so it covers
already-shipped printed CLIs without a regen.
Closes #1409
* fix(cli): guard scoped subprocess home with RWMutex; check test errors
Greptile flagged that scopedHomeDir was an unprotected package-level
global. Production entry points run sequentially, but the pipeline
package's own tests (synthetic_spec_test, dogfood_test, live_dogfood_test)
use t.Parallel(), and go test -race surfaces a real data race when those
tests concurrently invoke RunDogfood / RunVerify.
Add a sync.RWMutex around currentSubprocessHome and installScopedSubprocessHome
so the race detector is clean. Reads are RLock so the hot subprocessEnv()
path stays cheap; writes only happen at session install/restore.
Also fix the two test helpers that ignored scopeSubprocessHome's error
return; on a MkdirTemp failure the downstream assertions would have run
against an empty scoped home and produced a confusing failure mode.
---
internal/pipeline/dogfood.go | 8 ++
internal/pipeline/live_check.go | 8 ++
internal/pipeline/live_dogfood.go | 7 +
internal/pipeline/runtime.go | 9 +-
internal/pipeline/runtime_commands.go | 4 +
internal/pipeline/runtime_structural.go | 11 +-
internal/pipeline/subprocess_env.go | 163 +++++++++++++++++++++
internal/pipeline/subprocess_env_test.go | 239 +++++++++++++++++++++++++++++++
internal/pipeline/workflow_verify.go | 7 +
9 files changed, 448 insertions(+), 8 deletions(-)
diff --git a/internal/pipeline/dogfood.go b/internal/pipeline/dogfood.go
index 99049456..763dd05c 100644
--- a/internal/pipeline/dogfood.go
+++ b/internal/pipeline/dogfood.go
@@ -204,6 +204,12 @@ func (s *openAPISpec) IsSynthetic() bool {
}
func RunDogfood(dir, specPath string, opts ...DogfoodOption) (*DogfoodReport, error) {
+ releaseHome, err := scopeSubprocessHome()
+ if err != nil {
+ return nil, err
+ }
+ defer releaseHome()
+
cfg := dogfoodConfig{}
for _, o := range opts {
o(&cfg)
@@ -1761,6 +1767,7 @@ func runStdoutOnly(binaryPath string, timeout time.Duration, args ...string) ([]
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
cmd := exec.CommandContext(ctx, binaryPath, args...)
+ applyDefaultSubprocessEnv(cmd)
out, err := cmd.Output()
if ctx.Err() == context.DeadlineExceeded {
return nil, fmt.Errorf("timed out after %s", timeout)
@@ -1876,6 +1883,7 @@ func runDogfoodCmd(binary string, timeout time.Duration, args ...string) (string
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
cmd := exec.CommandContext(ctx, binary, args...)
+ applyDefaultSubprocessEnv(cmd)
out, err := cmd.CombinedOutput()
if err != nil && ctx.Err() == context.DeadlineExceeded {
return "", fmt.Errorf("timed out after %s", timeout)
diff --git a/internal/pipeline/live_check.go b/internal/pipeline/live_check.go
index 5418987b..62300394 100644
--- a/internal/pipeline/live_check.go
+++ b/internal/pipeline/live_check.go
@@ -136,6 +136,13 @@ type LiveCheckOptions struct {
// check doesn't penalize the CLI.
func RunLiveCheck(opts LiveCheckOptions) *LiveCheckResult {
out := &LiveCheckResult{RanAt: time.Now().UTC()}
+ releaseHome, err := scopeSubprocessHome()
+ if err != nil {
+ out.Unable = true
+ out.Reason = err.Error()
+ return out
+ }
+ defer releaseHome()
if opts.CLIDir == "" {
out.Unable = true
@@ -388,6 +395,7 @@ func runOneFeatureCheck(cliDir, binaryPath string, f NovelFeature, timeout time.
cmd := exec.CommandContext(ctx, binaryPath, args...)
cmd.Dir = cliDir
+ applyDefaultSubprocessEnv(cmd)
// Capture stdout into a bounded buffer. An unbounded `cmd.Output()` call
// would let a misbehaving feature exhaust the scorecard's memory.
stdoutCap := &bytes.Buffer{}
diff --git a/internal/pipeline/live_dogfood.go b/internal/pipeline/live_dogfood.go
index 20255f94..713b795c 100644
--- a/internal/pipeline/live_dogfood.go
+++ b/internal/pipeline/live_dogfood.go
@@ -94,6 +94,12 @@ type liveDogfoodRun struct {
}
func RunLiveDogfood(opts LiveDogfoodOptions) (*LiveDogfoodReport, error) {
+ releaseHome, err := scopeSubprocessHome()
+ if err != nil {
+ return nil, err
+ }
+ defer releaseHome()
+
if strings.TrimSpace(opts.CLIDir) == "" {
return nil, fmt.Errorf("CLIDir is required")
}
@@ -852,6 +858,7 @@ func runLiveDogfoodProcess(binaryPath, cliDir string, args []string, timeout tim
cmd := exec.CommandContext(ctx, binaryPath, args...)
cmd.Dir = cliDir
+ applyDefaultSubprocessEnv(cmd)
stdout := &bytes.Buffer{}
stderr := &bytes.Buffer{}
cmd.Stdout = &limitedWriter{w: stdout, remaining: MaxOutputBytes}
diff --git a/internal/pipeline/runtime.go b/internal/pipeline/runtime.go
index 0ef18a73..9dcff7f6 100644
--- a/internal/pipeline/runtime.go
+++ b/internal/pipeline/runtime.go
@@ -85,6 +85,11 @@ type FreshnessResult struct {
// RunVerify executes the runtime verification pipeline.
func RunVerify(cfg VerifyConfig) (*VerifyReport, error) {
+ releaseHome, err := scopeSubprocessHome()
+ if err != nil {
+ return nil, err
+ }
+ defer releaseHome()
if cfg.NoSpec {
return runStructuralVerify(cfg)
}
@@ -225,7 +230,7 @@ func RunVerify(cfg VerifyConfig) (*VerifyReport, error) {
// buildEnv constructs the environment for test subprocesses, passing
// all auth-related env vars so auth-requiring commands can complete.
buildEnv := func() []string {
- env := os.Environ()
+ env := subprocessEnv()
if report.Mode == "live" {
for _, ev := range authEnvVars {
if val := os.Getenv(ev); val != "" {
@@ -535,7 +540,7 @@ func runBrowserSessionProofTest(binary string, auth apispec.AuthConfig) CommandR
return result
}
- output, err := runCLIWithOutput(binary, []string{"doctor", "--json"}, os.Environ(), 20*time.Second)
+ output, err := runCLIWithOutput(binary, []string{"doctor", "--json"}, subprocessEnv(), 20*time.Second)
if err != nil {
result.Error = fmt.Sprintf("doctor --json failed: %v", err)
result.Score = 0
diff --git a/internal/pipeline/runtime_commands.go b/internal/pipeline/runtime_commands.go
index 5c1d666c..8dea76b8 100644
--- a/internal/pipeline/runtime_commands.go
+++ b/internal/pipeline/runtime_commands.go
@@ -28,6 +28,7 @@ func discoverCommandsFromHelp(binaryPath string) []discoveredCommand {
defer cancel()
helpCmd := exec.CommandContext(ctx, binaryPath, "--help")
+ applyDefaultSubprocessEnv(helpCmd)
out, err := helpCmd.CombinedOutput()
if err != nil {
return nil
@@ -127,6 +128,7 @@ func inferPositionalArgs(binary string, cmd *discoveredCommand, paramDefaults ma
defer cancel()
helpCmd := exec.CommandContext(ctx, binary, cmd.Name, "--help")
+ applyDefaultSubprocessEnv(helpCmd)
out, err := helpCmd.CombinedOutput()
if err != nil {
return // fall back to no extra args
@@ -308,6 +310,7 @@ func helpScanIndicatesSideEffect(binary string, cmd *discoveredCommand) bool {
defer cancel()
helpCmd := exec.CommandContext(ctx, binary, cmd.Name, "--help")
+ applyDefaultSubprocessEnv(helpCmd)
out, err := helpCmd.CombinedOutput()
if err != nil {
return false
@@ -431,6 +434,7 @@ func inferRequiredFlags(binary, cmdName string) []string {
defer cancel()
probe := exec.CommandContext(ctx, binary, cmdName)
+ applyDefaultSubprocessEnv(probe)
out, _ := probe.CombinedOutput() // error expected when flags are missing
m := requiredFlagsRe.FindSubmatch(out)
diff --git a/internal/pipeline/runtime_structural.go b/internal/pipeline/runtime_structural.go
index c57e6e66..b92ed061 100644
--- a/internal/pipeline/runtime_structural.go
+++ b/internal/pipeline/runtime_structural.go
@@ -2,7 +2,6 @@ package pipeline
import (
"fmt"
- "os"
"time"
"github.com/mvanhorn/cli-printing-press/v4/internal/artifacts"
@@ -38,9 +37,9 @@ func runStructuralVerify(cfg VerifyConfig) (*VerifyReport, error) {
report.Results = append(report.Results, result)
}
- versionOK := runCLI(binaryPath, []string{"version"}, os.Environ(), 10*time.Second) == nil
+ versionOK := runCLI(binaryPath, []string{"version"}, subprocessEnv(), 10*time.Second) == nil
if !versionOK {
- versionOK = runCLI(binaryPath, []string{"--version"}, os.Environ(), 10*time.Second) == nil
+ versionOK = runCLI(binaryPath, []string{"--version"}, subprocessEnv(), 10*time.Second) == nil
}
report.DataPipeline = versionOK
if versionOK {
@@ -63,14 +62,14 @@ func runStructuralCommandTests(binary string, cmd discoveredCommand) CommandResu
Kind: "structural",
}
- result.Help = runCLI(binary, []string{cmd.Name, "--help"}, os.Environ(), 10*time.Second) == nil
- result.DryRun = runCLI(binary, []string{cmd.Name, "--help", "--json"}, os.Environ(), 10*time.Second) == nil
+ result.Help = runCLI(binary, []string{cmd.Name, "--help"}, subprocessEnv(), 10*time.Second) == nil
+ result.DryRun = runCLI(binary, []string{cmd.Name, "--help", "--json"}, subprocessEnv(), 10*time.Second) == nil
switch cmd.Name {
case "doctor", "version", "auth", "completion", "api", "help":
result.Execute = true // these work without args
default:
- err := runCLI(binary, []string{cmd.Name, "--json"}, os.Environ(), 10*time.Second)
+ err := runCLI(binary, []string{cmd.Name, "--json"}, subprocessEnv(), 10*time.Second)
result.Execute = true
_ = err
}
diff --git a/internal/pipeline/subprocess_env.go b/internal/pipeline/subprocess_env.go
new file mode 100644
index 00000000..35194d8c
--- /dev/null
+++ b/internal/pipeline/subprocess_env.go
@@ -0,0 +1,163 @@
+package pipeline
+
+import (
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "sync"
+)
+
+// Subprocess HOME / XDG_CONFIG_HOME scoping for verify, dogfood, live-dogfood,
+// live-check, and workflow-verify runs. Without it, the printed CLI's
+// config-save paths (auth login, set-token, doctor repair, etc.) would
+// persist the mock BASE_URL / token values that verify injects to the
+// operator's real ~/.config/<api>-pp-cli/config.<format>.
+
+// configHomeEnvVars is the closed set of env vars Go's os.UserHomeDir,
+// os.UserConfigDir, and os.UserCacheDir consult (Unix + Windows). Rewriting
+// all of them together gives a printed CLI no path back to the operator's
+// real home regardless of which os.* helper its generated config code uses.
+var configHomeEnvVars = []string{
+ "HOME",
+ "XDG_CONFIG_HOME",
+ "XDG_CACHE_HOME",
+ "XDG_DATA_HOME",
+ "USERPROFILE",
+ "APPDATA",
+ "LOCALAPPDATA",
+}
+
+// newScopedConfigHome creates an ephemeral home root with the XDG
+// subtrees pre-created. Returns the path, a cleanup function (safe to
+// call once), and any creation error.
+func newScopedConfigHome() (string, func(), error) {
+ homeDir, err := os.MkdirTemp("", "printing-press-subprocess-")
+ if err != nil {
+ return "", func() {}, fmt.Errorf("creating subprocess home: %w", err)
+ }
+ cleanup := func() { _ = os.RemoveAll(homeDir) }
+ for _, sub := range []string{".config", ".cache", filepath.Join(".local", "share")} {
+ if err := os.MkdirAll(filepath.Join(homeDir, sub), 0o700); err != nil {
+ cleanup()
+ return "", func() {}, fmt.Errorf("creating subprocess %s: %w", sub, err)
+ }
+ }
+ return homeDir, cleanup, nil
+}
+
+// applyScopedConfigHome overlays the home-related env vars in env with
+// values rooted at homeDir so a receiving subprocess resolves
+// os.UserConfigDir / os.UserHomeDir under homeDir rather than the
+// parent's $HOME. Existing entries for the rewritten vars are dropped.
+// homeDir == "" returns env unchanged.
+func applyScopedConfigHome(env []string, homeDir string) []string {
+ if homeDir == "" {
+ return env
+ }
+ overrides := scopedConfigHomeOverrides(homeDir)
+ out := make([]string, 0, len(env)+len(overrides))
+ for _, kv := range env {
+ if isScopedConfigHomeEntry(kv) {
+ continue
+ }
+ out = append(out, kv)
+ }
+ for _, name := range configHomeEnvVars {
+ out = append(out, name+"="+overrides[name])
+ }
+ return out
+}
+
+// scopedConfigHomeOverrides maps each configHomeEnvVar to a path under
+// homeDir. APPDATA / XDG_CONFIG_HOME share .config so Windows and Linux
+// CLIs land in the same scoped dir; LOCALAPPDATA / XDG_CACHE_HOME share
+// .cache.
+func scopedConfigHomeOverrides(homeDir string) map[string]string {
+ configDir := filepath.Join(homeDir, ".config")
+ cacheDir := filepath.Join(homeDir, ".cache")
+ dataDir := filepath.Join(homeDir, ".local", "share")
+ return map[string]string{
+ "HOME": homeDir,
+ "XDG_CONFIG_HOME": configDir,
+ "XDG_CACHE_HOME": cacheDir,
+ "XDG_DATA_HOME": dataDir,
+ "USERPROFILE": homeDir,
+ "APPDATA": configDir,
+ "LOCALAPPDATA": cacheDir,
+ }
+}
+
+func isScopedConfigHomeEntry(kv string) bool {
+ for _, name := range configHomeEnvVars {
+ if strings.HasPrefix(kv, name+"=") {
+ return true
+ }
+ }
+ return false
+}
+
+// scopedHomeDir holds the active scoped home for child invocations of
+// the printed CLI. Production entry points run sequentially, but
+// pipeline tests use t.Parallel(), so the mutex protects against the
+// data race go test -race detects without it.
+var (
+ scopedHomeDirMu sync.RWMutex
+ scopedHomeDir string
+)
+
+// currentSubprocessHome returns the active scoped home or "" if none.
+func currentSubprocessHome() string {
+ scopedHomeDirMu.RLock()
+ defer scopedHomeDirMu.RUnlock()
+ return scopedHomeDir
+}
+
+// installScopedSubprocessHome installs homeDir as the active scoped
+// home and returns a restore function the caller defers.
+func installScopedSubprocessHome(homeDir string) func() {
+ scopedHomeDirMu.Lock()
+ prev := scopedHomeDir
+ scopedHomeDir = homeDir
+ scopedHomeDirMu.Unlock()
+ return func() {
+ scopedHomeDirMu.Lock()
+ scopedHomeDir = prev
+ scopedHomeDirMu.Unlock()
+ }
+}
+
+// scopeSubprocessHome installs a fresh scoped home for the current
+// entry point. Callers defer the returned cleanup to restore the
+// previous home and remove the tempdir. Returning the error rather
+// than silently falling back is deliberate: the whole fix exists to
+// prevent data corruption, and a torn scope would leave the bug
+// exposed.
+func scopeSubprocessHome() (func(), error) {
+ homeDir, removeHome, err := newScopedConfigHome()
+ if err != nil {
+ return func() {}, err
+ }
+ restore := installScopedSubprocessHome(homeDir)
+ return func() {
+ restore()
+ removeHome()
+ }, nil
+}
+
+// subprocessEnv returns os.Environ() with the active scoped home
+// overlaid, or os.Environ() unchanged when no session is active.
+func subprocessEnv() []string {
+ return applyScopedConfigHome(os.Environ(), currentSubprocessHome())
+}
+
+// applyDefaultSubprocessEnv installs subprocessEnv() on cmd if the
+// caller hasn't already chosen cmd.Env. Every exec site that runs the
+// printed CLI calls this so the child inherits the scoped HOME.
+func applyDefaultSubprocessEnv(cmd *exec.Cmd) {
+ if cmd == nil || cmd.Env != nil {
+ return
+ }
+ cmd.Env = subprocessEnv()
+}
diff --git a/internal/pipeline/subprocess_env_test.go b/internal/pipeline/subprocess_env_test.go
new file mode 100644
index 00000000..e86da163
--- /dev/null
+++ b/internal/pipeline/subprocess_env_test.go
@@ -0,0 +1,239 @@
+package pipeline
+
+import (
+ "context"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "runtime"
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestApplyScopedConfigHomeRewritesHomeVars(t *testing.T) {
+ home := t.TempDir()
+ env := []string{
+ "HOME=/Users/operator",
+ "XDG_CONFIG_HOME=/Users/operator/.config",
+ "USERPROFILE=C:\\Users\\operator",
+ "APPDATA=C:\\Users\\operator\\AppData\\Roaming",
+ "UNRELATED=keepme",
+ }
+
+ got := applyScopedConfigHome(env, home)
+
+ assertEnv(t, got, "HOME", home)
+ assertEnv(t, got, "XDG_CONFIG_HOME", filepath.Join(home, ".config"))
+ assertEnv(t, got, "XDG_CACHE_HOME", filepath.Join(home, ".cache"))
+ assertEnv(t, got, "USERPROFILE", home)
+ assertEnv(t, got, "APPDATA", filepath.Join(home, ".config"))
+ assertEnv(t, got, "UNRELATED", "keepme")
+
+ for _, kv := range got {
+ if strings.HasPrefix(kv, "HOME=/Users/operator") ||
+ strings.HasPrefix(kv, "XDG_CONFIG_HOME=/Users/operator") {
+ t.Fatalf("operator's real HOME leaked into scoped env: %q", kv)
+ }
+ }
+}
+
+func TestApplyScopedConfigHomeEmptyHomeNoOps(t *testing.T) {
+ in := []string{"HOME=/real", "XDG_CONFIG_HOME=/real/.config"}
+ got := applyScopedConfigHome(in, "")
+ if len(got) != len(in) {
+ t.Fatalf("expected env unchanged, got %v", got)
+ }
+ for i, kv := range got {
+ if kv != in[i] {
+ t.Fatalf("entry %d changed: got %q want %q", i, kv, in[i])
+ }
+ }
+}
+
+func TestNewScopedConfigHomeCreatesXDGSubdirs(t *testing.T) {
+ home, cleanup, err := newScopedConfigHome()
+ if err != nil {
+ t.Fatalf("newScopedConfigHome: %v", err)
+ }
+ defer cleanup()
+
+ for _, sub := range []string{".config", ".cache", filepath.Join(".local", "share")} {
+ path := filepath.Join(home, sub)
+ info, err := os.Stat(path)
+ if err != nil {
+ t.Fatalf("missing %s: %v", path, err)
+ }
+ if !info.IsDir() {
+ t.Fatalf("%s exists but is not a directory", path)
+ }
+ }
+}
+
+func TestScopeSubprocessHomeInstallsAndRestores(t *testing.T) {
+ prev := currentSubprocessHome()
+ cleanup, err := scopeSubprocessHome()
+ if err != nil {
+ t.Fatalf("scopeSubprocessHome: %v", err)
+ }
+ scoped := currentSubprocessHome()
+ if scoped == "" || scoped == prev {
+ t.Fatalf("expected scoped home != prev (%q); got %q", prev, scoped)
+ }
+ cleanup()
+ if got := currentSubprocessHome(); got != prev {
+ t.Fatalf("currentSubprocessHome after cleanup = %q, want %q", got, prev)
+ }
+ if _, err := os.Stat(scoped); !os.IsNotExist(err) {
+ t.Fatalf("scoped home %q not removed by cleanup (err=%v)", scoped, err)
+ }
+}
+
+func TestApplyDefaultSubprocessEnvPreservesExplicitEnv(t *testing.T) {
+ cleanup, err := scopeSubprocessHome()
+ if err != nil {
+ t.Fatalf("scopeSubprocessHome: %v", err)
+ }
+ defer cleanup()
+
+ cmd := &exec.Cmd{Env: []string{"FOO=bar"}}
+ applyDefaultSubprocessEnv(cmd)
+ if len(cmd.Env) != 1 || cmd.Env[0] != "FOO=bar" {
+ t.Fatalf("explicit env was overwritten: %v", cmd.Env)
+ }
+}
+
+func TestApplyDefaultSubprocessEnvInstallsScopedHome(t *testing.T) {
+ cleanup, err := scopeSubprocessHome()
+ if err != nil {
+ t.Fatalf("scopeSubprocessHome: %v", err)
+ }
+ defer cleanup()
+ home := currentSubprocessHome()
+
+ cmd := &exec.Cmd{}
+ applyDefaultSubprocessEnv(cmd)
+ if !containsEnv(cmd.Env, "HOME", home) {
+ t.Fatalf("HOME=%s not present in scoped env: %v", home, cmd.Env)
+ }
+ if !containsEnv(cmd.Env, "XDG_CONFIG_HOME", filepath.Join(home, ".config")) {
+ t.Fatalf("XDG_CONFIG_HOME not present in scoped env: %v", cmd.Env)
+ }
+}
+
+// TestSubprocessWriteDoesNotEscapeScopedHome is the acceptance test for
+// issue #1409: a subprocess invoked under a scoped session must not write
+// to the parent's HOME-resolved config dir. The parent's HOME is
+// redirected to a sentinel tempdir via t.Setenv so the test never
+// touches the operator's real ~/.config/, even on a crash before
+// cleanup runs.
+func TestSubprocessWriteDoesNotEscapeScopedHome(t *testing.T) {
+ if testing.Short() {
+ t.Skip("compiles a probe binary; skipped under -short")
+ }
+ if runtime.GOOS == "windows" {
+ t.Skip("HOME-shaped probe is POSIX-only")
+ }
+
+ parentHome := t.TempDir()
+ t.Setenv("HOME", parentHome)
+ sentinelDir := filepath.Join(parentHome, ".config", "cli-printing-press-test")
+ sentinelPath := filepath.Join(sentinelDir, "config.toml")
+ if err := os.MkdirAll(sentinelDir, 0o700); err != nil {
+ t.Fatalf("mkdir sentinel dir: %v", err)
+ }
+ const sentinelValue = "sentinel-must-survive"
+ if err := os.WriteFile(sentinelPath, []byte(sentinelValue), 0o600); err != nil {
+ t.Fatalf("write sentinel: %v", err)
+ }
+
+ probeDir := t.TempDir()
+ probeSrc := filepath.Join(probeDir, "main.go")
+ if err := os.WriteFile(probeSrc, []byte(probeProgram), 0o600); err != nil {
+ t.Fatalf("write probe source: %v", err)
+ }
+ probeBin := filepath.Join(probeDir, "probe")
+ buildCtx, buildCancel := context.WithTimeout(context.Background(), 60*time.Second)
+ defer buildCancel()
+ build := exec.CommandContext(buildCtx, "go", "build", "-o", probeBin, probeSrc)
+ if out, err := build.CombinedOutput(); err != nil {
+ t.Fatalf("build probe: %v\n%s", err, out)
+ }
+
+ cleanup, err := scopeSubprocessHome()
+ if err != nil {
+ t.Fatalf("scopeSubprocessHome: %v", err)
+ }
+ defer cleanup()
+ scopedHome := currentSubprocessHome()
+
+ runCtx, runCancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer runCancel()
+ cmd := exec.CommandContext(runCtx, probeBin)
+ applyDefaultSubprocessEnv(cmd)
+ if out, err := cmd.CombinedOutput(); err != nil {
+ t.Fatalf("run probe: %v\n%s", err, out)
+ }
+
+ got, err := os.ReadFile(sentinelPath)
+ if err != nil {
+ t.Fatalf("read sentinel after probe: %v", err)
+ }
+ if string(got) != sentinelValue {
+ t.Fatalf("sentinel clobbered: got %q want %q", string(got), sentinelValue)
+ }
+
+ scopedConfig := filepath.Join(scopedHome, ".config", "cli-printing-press-test", "config.toml")
+ if _, err := os.Stat(scopedConfig); err != nil {
+ t.Fatalf("probe did not write into scoped home (%s): %v", scopedConfig, err)
+ }
+}
+
+// probeProgram replicates the shape of the generated config-save path:
+// resolves HOME, writes to $HOME/.config/<cli>/config.toml.
+const probeProgram = `package main
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+)
+
+func main() {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+ }
+ dir := filepath.Join(home, ".config", "cli-printing-press-test")
+ if err := os.MkdirAll(dir, 0o700); err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+ }
+ path := filepath.Join(dir, "config.toml")
+ if err := os.WriteFile(path, []byte("written-by-probe"), 0o600); err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+ }
+}
+`
+
+func assertEnv(t *testing.T, env []string, name, want string) {
+ t.Helper()
+ if !containsEnv(env, name, want) {
+ t.Fatalf("env missing %s=%q; got %v", name, want, env)
+ }
+}
+
+func containsEnv(env []string, name, want string) bool {
+ prefix := name + "="
+ for _, kv := range env {
+ if !strings.HasPrefix(kv, prefix) {
+ continue
+ }
+ if strings.TrimPrefix(kv, prefix) == want {
+ return true
+ }
+ }
+ return false
+}
diff --git a/internal/pipeline/workflow_verify.go b/internal/pipeline/workflow_verify.go
index 152213c4..c6d56664 100644
--- a/internal/pipeline/workflow_verify.go
+++ b/internal/pipeline/workflow_verify.go
@@ -16,6 +16,12 @@ import (
// RunWorkflowVerification builds the CLI and runs all workflows from the manifest.
func RunWorkflowVerification(dir string) (*WorkflowVerifyReport, error) {
+ releaseHome, err := scopeSubprocessHome()
+ if err != nil {
+ return nil, err
+ }
+ defer releaseHome()
+
manifest, err := LoadWorkflowManifest(dir)
if err != nil {
return nil, fmt.Errorf("loading workflow manifest: %w", err)
@@ -146,6 +152,7 @@ func executeStep(binary string, step WorkflowStep, cmdExpanded string, dir strin
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
cmd := exec.CommandContext(ctx, binary, args...)
cmd.Dir = dir
+ applyDefaultSubprocessEnv(cmd)
out, err := cmd.CombinedOutput()
cancel()
← 33a6668b fix(cli): handle single-quoted args in validate-narrative sh
·
back to Cli Printing Press
·
feat(skills): contain session-state.json outside DISCOVERY_D 55b0f16b →