diff --git a/docs/concurrency.md b/docs/concurrency.md index e28da426..04147d27 100644 --- a/docs/concurrency.md +++ b/docs/concurrency.md @@ -248,6 +248,13 @@ Shell and kernel subprocesses run in managed process groups. This is operational containment, not a security sandbox against another hostile process already running as the same OS user. +Managed host processes inherit the Go build settings `GOCACHE`, `GOMODCACHE`, +`GOPATH`, `GOROOT`, `GOTOOLCHAIN`, and `GOFLAGS` captured when the process manager +starts. This preserves preloaded modules and toolchains in offline environments. +Explicit child-process settings take precedence over that snapshot. Other Go +variables, including credential-bearing proxy/auth settings, are not implicitly +inherited; the allowlist is in `internal/capability/process.go`. + ## Gateway process ownership The socket-only daemon owns execution; `internal/webgateway` owns HTTP/WS diff --git a/internal/capability/process.go b/internal/capability/process.go index 7820d806..7736964a 100644 --- a/internal/capability/process.go +++ b/internal/capability/process.go @@ -372,6 +372,10 @@ func allowedBaseEnvironment(name string) bool { "TMPDIR", "TMP", "TEMP", "LANG", "LANGUAGE", "TERM", "COLORTERM", "NO_COLOR": return true + case "GOCACHE", "GOMODCACHE", "GOPATH", "GOROOT", "GOTOOLCHAIN", "GOFLAGS": + // Keep the host's Go toolchain and preloaded caches usable offline. + // Do not allow GO* wholesale: proxy/auth settings can carry credentials. + return true } return name == buildinfo.Env("HOME") || strings.HasPrefix(name, "LC_") } diff --git a/internal/capability/process_go_test.go b/internal/capability/process_go_test.go new file mode 100644 index 00000000..56490631 --- /dev/null +++ b/internal/capability/process_go_test.go @@ -0,0 +1,65 @@ +//go:build darwin || linux + +package capability + +import ( + "bytes" + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestProcessGoUsesPreloadedModuleCacheOffline(t *testing.T) { + goBin, err := exec.LookPath("go") + if err != nil { + t.Skip("Go executable is not on PATH") + } + work := t.TempDir() + cache := t.TempDir() + const dependency = "example.com/whip-cache-test" + // Listing the module graph needs cached metadata, so this fixture exercises + // a real Go command without downloads or toolchain compilation. + mod := filepath.Join(cache, "cache", "download", dependency, "@v", "v1.0.0.mod") + if err := os.MkdirAll(filepath.Dir(mod), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(mod, []byte("module "+dependency+"\n\ngo 1.20\n"), 0o644); err != nil { + t.Fatal(err) + } + info := filepath.Join(filepath.Dir(mod), "v1.0.0.info") + if err := os.WriteFile(info, []byte(`{"Version":"v1.0.0","Time":"2020-01-01T00:00:00Z"}`), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(work, "go.mod"), []byte("module example.com/app\n\ngo 1.20\n\nrequire "+dependency+" v1.0.0\n"), 0o644); err != nil { + t.Fatal(err) + } + t.Setenv("GOMODCACHE", cache) + t.Setenv("GOCACHE", t.TempDir()) + t.Setenv("GOPATH", t.TempDir()) + t.Setenv("GOTOOLCHAIN", "local") + t.Setenv("GOFLAGS", "-mod=mod") + m := NewProcessManager() + t.Cleanup(func() { _ = m.Close() }) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + var stdout, stderr bytes.Buffer + p, err := m.Start(ctx, "root", goBin, []string{"list", "-m", "all"}, ProcessOptions{ + Cwd: work, + // Explicitly prohibit downloads and ignore host-specific Go settings. + Env: map[string]string{"GOPROXY": "off", "GOSUMDB": "off", "GOENV": "off", "GOWORK": "off"}, + Stdin: strings.NewReader(""), Stdout: &stdout, Stderr: &stderr, + }) + if err != nil { + t.Fatal(err) + } + if err := p.Wait(); err != nil { + t.Fatalf("offline module lookup failed: %v\n%s", err, stderr.String()) + } + if !strings.Contains(stdout.String(), dependency+" v1.0.0\n") { + t.Fatalf("cached dependency missing from module graph: %s", stdout.String()) + } +} diff --git a/internal/capability/process_test.go b/internal/capability/process_test.go index 7ff08c9a..1bfede6a 100644 --- a/internal/capability/process_test.go +++ b/internal/capability/process_test.go @@ -61,8 +61,23 @@ func TestProcessEnvironmentCwdAndDescriptors(t *testing.T) { t.Setenv("SSH_AUTH_SOCK", "/daemon-agent") t.Setenv("RANDOM_DAEMON_SECRET", "daemon-random") t.Setenv("WHIPCODE_HOME", "/snapshot-whip-home") + goEnvironment := map[string]string{ + "GOCACHE": "/snapshot-go-build-cache", + "GOMODCACHE": "/snapshot-go-module-cache", + "GOPATH": "/snapshot-gopath", + "GOROOT": "/snapshot-goroot", + "GOTOOLCHAIN": "local", + "GOFLAGS": "-mod=mod", + } + for name, value := range goEnvironment { + t.Setenv(name, value) + } + t.Setenv("GOPROXY", "https://user:secret@proxy.invalid") + t.Setenv("GOAUTH", "daemon-auth-command") + t.Setenv("GO_PRIVATE_TOKEN", "daemon-go-secret") m := NewProcessManager() t.Setenv("HOME", "/changed-after-snapshot") + t.Setenv("GOMODCACHE", "/changed-after-snapshot") f, err := os.CreateTemp(t.TempDir(), "descriptor") if err != nil { @@ -112,7 +127,12 @@ func TestProcessEnvironmentCwdAndDescriptors(t *testing.T) { t.Errorf("%s = %q, want %q", name, env[name], want) } } - for _, name := range []string{"PROVIDER_API_KEY", "SSH_AUTH_SOCK", "RANDOM_DAEMON_SECRET"} { + for name, want := range goEnvironment { + if env[name] != want { + t.Errorf("%s = %q, want %q", name, env[name], want) + } + } + for _, name := range []string{"PROVIDER_API_KEY", "SSH_AUTH_SOCK", "RANDOM_DAEMON_SECRET", "GOPROXY", "GOAUTH", "GO_PRIVATE_TOKEN"} { if _, ok := env[name]; ok { t.Errorf("daemon environment leaked %s", name) } @@ -130,6 +150,21 @@ func TestProcessEnvironmentCwdAndDescriptors(t *testing.T) { if built, err := m.environment(nil); err != nil || !slices.IsSorted(built) { t.Errorf("environment not deterministically sorted: %v, %v", built, err) } + // Launchers that own exec.Cmd receive the captured build settings too; + // explicit per-process values still take precedence. + child, err := m.ChildEnvironment(map[string]string{"GOFLAGS": "-mod=readonly"}) + if err != nil { + t.Fatal(err) + } + childEnv := envMap(child) + for name, want := range goEnvironment { + if name == "GOFLAGS" { + want = "-mod=readonly" + } + if childEnv[name] != want { + t.Errorf("ChildEnvironment %s = %q, want %q", name, childEnv[name], want) + } + } if err := m.Close(); err != nil { t.Fatal(err) }