Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions docs/concurrency.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions internal/capability/process.go
Original file line number Diff line number Diff line change
Expand Up @@ -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_")
}
Expand Down
65 changes: 65 additions & 0 deletions internal/capability/process_go_test.go
Original file line number Diff line number Diff line change
@@ -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())
}
}
37 changes: 36 additions & 1 deletion internal/capability/process_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}
Expand All @@ -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)
}
Expand Down
Loading