26f897c1ec
release / release-please (push) Failing after 1m49s
docs / build (push) Failing after 6m34s
release / build-and-upload (arm64, linux) (push) Has been cancelled
release / build-and-upload (arm64, windows) (push) Has been cancelled
release / build-darwin (amd64, darwin) (push) Has been cancelled
release / checksums (push) Has been cancelled
release / finalize (push) Has been cancelled
release / build-darwin (arm64, darwin) (push) Has been cancelled
release / build-and-upload (amd64, linux) (push) Has been cancelled
release / build-and-upload (amd64, windows) (push) Has been cancelled
docs / deploy (push) Has been cancelled
334 lines
8.4 KiB
Go
334 lines
8.4 KiB
Go
package shellenv
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"os/exec"
|
|
"os/user"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
var runtimeGOOS = runtime.GOOS
|
|
var lookupEnv = os.LookupEnv
|
|
var currentUser = user.Current
|
|
var shellCommandOutput = defaultShellCommandOutput
|
|
|
|
// shellCommandTimeout bounds the one-time login-shell probe at daemon startup.
|
|
// It is deliberately forgiving: a 2s budget was too tight for an interactive
|
|
// login shell under cold-start/system load, and exceeding it silently dropped
|
|
// the daemon onto a degraded fallback PATH that omitted version-manager dirs
|
|
// (nvm/fnm/volta), the intermittent trigger behind #143.
|
|
var shellCommandTimeout = 30 * time.Second
|
|
|
|
var cacheMu sync.Mutex
|
|
var cachedEnv []string
|
|
|
|
func LoginShell() string {
|
|
if shell, ok := lookupEnv("SHELL"); ok && strings.TrimSpace(shell) != "" {
|
|
return shell
|
|
}
|
|
if runtimeGOOS == "darwin" {
|
|
if shell := shellFromDSCL(); shell != "" {
|
|
return shell
|
|
}
|
|
}
|
|
if runtimeGOOS == "linux" {
|
|
if shell := shellFromGetent(); shell != "" {
|
|
return shell
|
|
}
|
|
}
|
|
return "bash"
|
|
}
|
|
|
|
func SupportsInteractive(shell string) bool {
|
|
base := filepath.Base(shell)
|
|
return base == "bash" || base == "zsh"
|
|
}
|
|
|
|
func Resolve() ([]string, error) {
|
|
cacheMu.Lock()
|
|
if cachedEnv != nil {
|
|
defer cacheMu.Unlock()
|
|
return append([]string(nil), cachedEnv...), nil
|
|
}
|
|
cacheMu.Unlock()
|
|
|
|
resolved, resolvedFromShell := resolveUncached()
|
|
|
|
// Only cache a successful shell resolution. A degraded fallback (the shell
|
|
// probe failed or returned nothing) must never be cached: one bad daemon
|
|
// startup would otherwise poison every spawned agent for the daemon's whole
|
|
// lifetime - the failure mode behind #143. Leaving it uncached lets a later
|
|
// call retry and recover the real login-shell PATH.
|
|
if resolvedFromShell {
|
|
cacheMu.Lock()
|
|
if cachedEnv == nil {
|
|
cachedEnv = append([]string(nil), resolved...)
|
|
}
|
|
cacheMu.Unlock()
|
|
}
|
|
return append([]string(nil), resolved...), nil
|
|
}
|
|
|
|
func ApplyToProcess() error {
|
|
env, err := Resolve()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, entry := range env {
|
|
key, value, found := strings.Cut(entry, "=")
|
|
if key == "" {
|
|
continue
|
|
}
|
|
if !found {
|
|
value = ""
|
|
}
|
|
if err := os.Setenv(key, value); err != nil {
|
|
return fmt.Errorf("set %s: %w", key, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// resolveUncached probes the login shell for its environment. The bool return
|
|
// reports whether the result came from a successful shell probe (true) or a
|
|
// degraded fallback (false); callers use it to decide whether the result is
|
|
// safe to cache.
|
|
func resolveUncached() ([]string, bool) {
|
|
if runtimeGOOS == "windows" {
|
|
return append([]string(nil), os.Environ()...), true
|
|
}
|
|
|
|
shell := LoginShell()
|
|
args := []string{"-l", "-c", "env -0"}
|
|
if SupportsInteractive(shell) {
|
|
args = []string{"-l", "-i", "-c", "env -0"}
|
|
}
|
|
out, err := shellCommandOutput(shell, args...)
|
|
if err != nil {
|
|
slog.Warn("login shell environment resolution failed; using a degraded fallback PATH that may omit version-manager dirs (nvm/fnm/volta), so agents may not find tools like pnpm (#143)",
|
|
"shell", shell, "err", err)
|
|
fallback := append([]string(nil), os.Environ()...)
|
|
return augmentPath(ensureShellEntry(fallback, shell)), false
|
|
}
|
|
resolved := parseEnvOutput(out)
|
|
if len(resolved) == 0 {
|
|
slog.Warn("login shell environment resolution returned no entries; using a degraded fallback PATH that may omit version-manager dirs (nvm/fnm/volta), so agents may not find tools like pnpm (#143)",
|
|
"shell", shell)
|
|
fallback := append([]string(nil), os.Environ()...)
|
|
return augmentPath(ensureShellEntry(fallback, shell)), false
|
|
}
|
|
return augmentPath(ensureShellEntry(resolved, shell)), true
|
|
}
|
|
|
|
// WellKnownBinDirs returns common binary install locations that should be on
|
|
// PATH for tools like Homebrew, user-local installs, and language package
|
|
// managers (Go, Rust). Non-existent directories are included unchanged
|
|
// because Go's exec.LookPath ignores missing PATH entries - filtering here
|
|
// would require filesystem access in resolution, which complicates testing
|
|
// without adding value for daemon launch.
|
|
func WellKnownBinDirs() []string {
|
|
return WellKnownBinDirsForHome(homeDir())
|
|
}
|
|
|
|
func WellKnownBinDirsForHome(home string) []string {
|
|
dirs := []string{}
|
|
if strings.TrimSpace(home) != "" {
|
|
dirs = append(dirs,
|
|
filepath.Join(home, ".local", "bin"),
|
|
filepath.Join(home, "go", "bin"),
|
|
filepath.Join(home, ".cargo", "bin"),
|
|
filepath.Join(home, "bin"),
|
|
)
|
|
}
|
|
dirs = append(dirs,
|
|
"/opt/homebrew/bin",
|
|
"/opt/homebrew/sbin",
|
|
"/usr/local/bin",
|
|
"/usr/local/sbin",
|
|
"/usr/bin",
|
|
"/bin",
|
|
"/usr/sbin",
|
|
"/sbin",
|
|
)
|
|
return dirs
|
|
}
|
|
|
|
func homeDir() string {
|
|
if home, ok := lookupEnv("HOME"); ok && strings.TrimSpace(home) != "" {
|
|
return home
|
|
}
|
|
u, err := currentUser()
|
|
if err != nil || u == nil {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(u.HomeDir)
|
|
}
|
|
|
|
// augmentPath merges WellKnownBinDirs into the PATH entry of env, preserving
|
|
// existing entries in order (so user-configured PATH continues to win) and
|
|
// appending any well-known dirs that are not already present. If env lacks a
|
|
// PATH entry entirely, one is synthesized from WellKnownBinDirs.
|
|
func augmentPath(env []string) []string {
|
|
sep := string(os.PathListSeparator)
|
|
pathIdx := -1
|
|
var existing []string
|
|
for i, entry := range env {
|
|
if strings.HasPrefix(entry, "PATH=") {
|
|
pathIdx = i
|
|
raw := strings.TrimPrefix(entry, "PATH=")
|
|
if raw != "" {
|
|
existing = strings.Split(raw, sep)
|
|
}
|
|
break
|
|
}
|
|
}
|
|
seen := make(map[string]struct{}, len(existing))
|
|
for _, p := range existing {
|
|
seen[p] = struct{}{}
|
|
}
|
|
for _, d := range WellKnownBinDirs() {
|
|
if _, ok := seen[d]; ok {
|
|
continue
|
|
}
|
|
existing = append(existing, d)
|
|
seen[d] = struct{}{}
|
|
}
|
|
merged := "PATH=" + strings.Join(existing, sep)
|
|
if pathIdx >= 0 {
|
|
env[pathIdx] = merged
|
|
} else {
|
|
env = append(env, merged)
|
|
}
|
|
return env
|
|
}
|
|
|
|
func parseEnvOutput(out []byte) []string {
|
|
parts := strings.Split(string(out), "\x00")
|
|
env := make([]string, 0, len(parts))
|
|
for _, part := range parts {
|
|
entry, ok := parseEnvEntry(part)
|
|
if !ok {
|
|
continue
|
|
}
|
|
env = append(env, entry)
|
|
}
|
|
return env
|
|
}
|
|
|
|
func parseEnvEntry(part string) (string, bool) {
|
|
if part == "" {
|
|
return "", false
|
|
}
|
|
candidateStarts := []int{0}
|
|
for i := 0; i < len(part); i++ {
|
|
if part[i] == '\n' || part[i] == '\r' {
|
|
candidateStarts = append(candidateStarts, i+1)
|
|
}
|
|
}
|
|
for _, start := range candidateStarts {
|
|
candidate := strings.TrimLeft(part[start:], "\r\n")
|
|
if candidate == "" {
|
|
continue
|
|
}
|
|
key, _, found := strings.Cut(candidate, "=")
|
|
if found && validEnvKey(key) {
|
|
return candidate, true
|
|
}
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
func validEnvKey(key string) bool {
|
|
if key == "" {
|
|
return false
|
|
}
|
|
for i, r := range key {
|
|
if i == 0 {
|
|
if !((r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') || r == '_') {
|
|
return false
|
|
}
|
|
continue
|
|
}
|
|
if !((r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '_') {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func ensureShellEntry(env []string, shell string) []string {
|
|
for _, entry := range env {
|
|
if strings.HasPrefix(entry, "SHELL=") {
|
|
return env
|
|
}
|
|
}
|
|
return append(env, "SHELL="+shell)
|
|
}
|
|
|
|
func shellFromDSCL() string {
|
|
username := currentUsername()
|
|
if username == "" {
|
|
return ""
|
|
}
|
|
out, err := shellCommandOutput("dscl", ".", "-read", "/Users/"+username, "UserShell")
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
line := strings.TrimSpace(string(out))
|
|
line = strings.TrimPrefix(line, "UserShell:")
|
|
return strings.TrimSpace(line)
|
|
}
|
|
|
|
func shellFromGetent() string {
|
|
username := currentUsername()
|
|
if username == "" {
|
|
return ""
|
|
}
|
|
out, err := shellCommandOutput("getent", "passwd", username)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
line := strings.TrimSpace(string(out))
|
|
if line == "" {
|
|
return ""
|
|
}
|
|
parts := strings.Split(line, ":")
|
|
if len(parts) == 0 {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(parts[len(parts)-1])
|
|
}
|
|
|
|
func currentUsername() string {
|
|
if username, ok := lookupEnv("USER"); ok && strings.TrimSpace(username) != "" {
|
|
return username
|
|
}
|
|
u, err := currentUser()
|
|
if err != nil || u == nil {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(u.Username)
|
|
}
|
|
|
|
func defaultShellCommandOutput(name string, args ...string) ([]byte, error) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), shellCommandTimeout)
|
|
defer cancel()
|
|
cmd := exec.CommandContext(ctx, name, args...)
|
|
ConfigureShellCommand(cmd)
|
|
cmd.WaitDelay = 100 * time.Millisecond
|
|
return OutputShellCommand(cmd)
|
|
}
|
|
|
|
func resetForTests() {
|
|
cacheMu.Lock()
|
|
defer cacheMu.Unlock()
|
|
cachedEnv = nil
|
|
}
|